mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
51
blocksuite/framework/std/package.json
Normal file
51
blocksuite/framework/std/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@blocksuite/std",
|
||||
"description": "Std for blocksuite blocks",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.2.4",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lib0": "^0.2.97",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lz-string": "^1.5.0",
|
||||
"rehype-parse": "^9.0.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"unified": "^11.0.5",
|
||||
"w3c-keyname": "^2.2.8",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "3.0.9"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./gfx": "./src/gfx/index.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./inline": "./src/inline/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
414
blocksuite/framework/std/src/__tests__/command.unit.spec.ts
Normal file
414
blocksuite/framework/std/src/__tests__/command.unit.spec.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Command } from '../command/index.js';
|
||||
import { CommandManager } from '../command/index.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
|
||||
type Command1 = Command<
|
||||
{
|
||||
command1Option?: string;
|
||||
},
|
||||
{
|
||||
commandData1: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type Command2 = Command<{ commandData1: string }, { commandData2: string }>;
|
||||
|
||||
describe('CommandManager', () => {
|
||||
let std: BlockStdScope;
|
||||
let commandManager: CommandManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
std = {};
|
||||
commandManager = new CommandManager(std);
|
||||
});
|
||||
|
||||
test('can add and execute a command', () => {
|
||||
const command1: Command1 = vi.fn((_ctx, next) => next());
|
||||
const command2: Command2 = vi.fn((_ctx, _next) => {});
|
||||
|
||||
const [success1] = commandManager.chain().pipe(command1, {}).run();
|
||||
const [success2] = commandManager
|
||||
.chain()
|
||||
.pipe(command1, {})
|
||||
.pipe(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());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(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());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(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());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.pipe(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: Command1 = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(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<{ commandData1: string }> = vi.fn((_ctx, next) =>
|
||||
next()
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.with({ commandData1: 'test' })
|
||||
.pipe(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<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: '123' })
|
||||
);
|
||||
const command2: Command<{ commandData1: string }> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('123');
|
||||
next({ commandData1: '456' });
|
||||
});
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.pipe(command2)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalled();
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
expect(ctx.commandData1).toBe('456');
|
||||
});
|
||||
|
||||
test('should not continue with the rest of the chain if all commands in `try` fail', () => {
|
||||
const command1: Command = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(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: Command<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: '123' })
|
||||
);
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command = vi.fn((_ctx, next) => next());
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.try(chain => [chain.pipe(command2), chain.pipe(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: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '123' }));
|
||||
const command3: Command<{}, { commandData3: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData3: '456' })
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(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: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData1: '123' }));
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '456' }));
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.try(chain => [chain.pipe(command1), chain.pipe(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 = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
expect(ctx.commandData2).toBe('fromCommand1');
|
||||
// override commandData2
|
||||
next({ commandData2: 'fromCommand2' });
|
||||
});
|
||||
const command3: Command = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
expect(ctx.commandData2).toBe('fromCommand2');
|
||||
next();
|
||||
});
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.try(chain => [chain.pipe(command2)])
|
||||
.pipe(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());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(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: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData1: '123' }));
|
||||
const command2: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData2: '456' }));
|
||||
const command3: Command<
|
||||
{},
|
||||
{ commandData1?: string; commandData2?: string; commandData3?: string }
|
||||
> = vi.fn((_ctx, next) => next({ commandData3: '789' }));
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [
|
||||
chain.pipe(command1),
|
||||
chain.pipe(command2),
|
||||
chain.pipe(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: Command = vi.fn((_ctx, _next) => {});
|
||||
const command2: Command = vi.fn((_ctx, _next) => {});
|
||||
const command3: Command<{}, { commandData3: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData3: '123' })
|
||||
);
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.tryAll(chain => [chain.pipe(command1), chain.pipe(command2)])
|
||||
.pipe(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<{}, { commandData1: string }> = vi.fn(
|
||||
(_ctx, next) => next({ commandData1: 'fromCommand1' })
|
||||
);
|
||||
const command2: Command<
|
||||
{ commandData1: string; commandData2?: string },
|
||||
{ commandData2: string; commandData3: string }
|
||||
> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand1');
|
||||
// override commandData1
|
||||
next({ commandData1: 'fromCommand2', commandData2: 'fromCommand2' });
|
||||
});
|
||||
const command3: Command<
|
||||
{ commandData1: string; commandData2?: string },
|
||||
{ commandData2: string; commandData3: string }
|
||||
> = 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: string; commandData2: string; commandData3: string },
|
||||
{}
|
||||
> = vi.fn((ctx, next) => {
|
||||
expect(ctx.commandData1).toBe('fromCommand2');
|
||||
expect(ctx.commandData2).toBe('fromCommand3');
|
||||
expect(ctx.commandData3).toBe('fromCommand3');
|
||||
next();
|
||||
});
|
||||
|
||||
const [success, ctx] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.tryAll(chain => [chain.pipe(command2), chain.pipe(command3)])
|
||||
.pipe(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());
|
||||
|
||||
const [success] = commandManager
|
||||
.chain()
|
||||
.pipe(command1)
|
||||
.tryAll(chain => [chain.pipe(command2), chain.pipe(command3)])
|
||||
.pipe(command4)
|
||||
.run();
|
||||
|
||||
expect(command1).toHaveBeenCalledTimes(1);
|
||||
expect(command2).toHaveBeenCalled();
|
||||
expect(command3).toHaveBeenCalled();
|
||||
expect(command4).toHaveBeenCalled();
|
||||
expect(success).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
createAutoIncrementIdGenerator,
|
||||
TestWorkspace,
|
||||
} from '@blocksuite/store/test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { effects } from '../effects.js';
|
||||
import { TestEditorContainer } from './test-editor.js';
|
||||
import {
|
||||
type HeadingBlockModel,
|
||||
HeadingBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from './test-schema.js';
|
||||
import { testSpecs } from './test-spec.js';
|
||||
|
||||
effects();
|
||||
|
||||
const extensions = [
|
||||
RootBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
HeadingBlockSchemaExtension,
|
||||
];
|
||||
|
||||
function createTestOptions() {
|
||||
const idGenerator = createAutoIncrementIdGenerator();
|
||||
return { id: 'test-collection', idGenerator };
|
||||
}
|
||||
|
||||
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 TestWorkspace(createTestOptions());
|
||||
|
||||
collection.meta.initialize();
|
||||
const doc = collection.createDoc('home');
|
||||
const store = doc.getStore({ extensions });
|
||||
doc.load();
|
||||
const rootId = store.addBlock('test:page');
|
||||
const noteId = store.addBlock('test:note', {}, rootId);
|
||||
const headingId = store.addBlock('test:heading', { type: 'h1' }, noteId);
|
||||
const headingBlock = store.getBlock(headingId)!;
|
||||
|
||||
const editorContainer = new TestEditorContainer();
|
||||
editorContainer.doc = store;
|
||||
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).props.type = 'h2';
|
||||
await wait(50);
|
||||
headingElm = editorContainer.std.view.getBlock(headingId);
|
||||
|
||||
expect(headingElm!.tagName).toBe('TEST-H2-BLOCK');
|
||||
});
|
||||
});
|
||||
65
blocksuite/framework/std/src/__tests__/hast.unit.spec.ts
Normal file
65
blocksuite/framework/std/src/__tests__/hast.unit.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { onlyContainImgElement } from '../clipboard/utils.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,145 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
deltaInsertsToChunks,
|
||||
transformDelta,
|
||||
} from '../../inline/utils/delta-convert.js';
|
||||
|
||||
test('transformDelta', () => {
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
transformDelta({
|
||||
insert: '\n\naaa\n\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
'\n',
|
||||
'\n',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deltaInsertsToChunks', () => {
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\nbbb\n\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
|
||||
expect(
|
||||
deltaInsertsToChunks([
|
||||
{
|
||||
insert: '\n\naaa\n',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: '\nbbb\n\n',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { InlineEditor } from '../../inline/index.js';
|
||||
|
||||
test('getDeltaByRangeIndex', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(0)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(1)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(3)).toEqual({
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(inlineEditor.getDeltaByRangeIndex(4)).toEqual({
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('getDeltasByInlineRange', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.applyDelta([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 0,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 3,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 3,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 0,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 1,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 2,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
inlineEditor.getDeltasByInlineRange({
|
||||
index: 4,
|
||||
length: 4,
|
||||
})
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
length: 3,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cursor with format', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.setMarks({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb'
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getFormat', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 0 })).toEqual({});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 1 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 0, length: 3 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 0 })).toEqual({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 1 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 3, length: 3 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(inlineEditor.getFormat({ index: 6, length: 0 })).toEqual({
|
||||
italic: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('incorrect format value `false`', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
const inlineEditor = new InlineEditor(yText);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
'aaa',
|
||||
{
|
||||
// @ts-expect-error insert incorrect value
|
||||
bold: false,
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: 3,
|
||||
length: 0,
|
||||
},
|
||||
'bbb',
|
||||
{
|
||||
underline: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(inlineEditor.yText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('yText should not contain \r', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const yText = yDoc.getText('text');
|
||||
yText.insert(0, 'aaa\r');
|
||||
|
||||
expect(yText.toString()).toEqual('aaa\r');
|
||||
expect(() => {
|
||||
new InlineEditor(yText);
|
||||
}).toThrow(
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
intersectInlineRange,
|
||||
isInlineRangeAfter,
|
||||
isInlineRangeBefore,
|
||||
isInlineRangeContain,
|
||||
isInlineRangeEdge,
|
||||
isInlineRangeEdgeAfter,
|
||||
isInlineRangeEdgeBefore,
|
||||
isInlineRangeEqual,
|
||||
isInlineRangeIntersect,
|
||||
isPoint,
|
||||
mergeInlineRange,
|
||||
} from '../../inline/utils/inline-range.js';
|
||||
|
||||
test('isInlineRangeContain', () => {
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 2 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 4 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeEqual', () => {
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 2 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 1, length: 3 }, { index: 1, length: 3 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeEqual({ index: 2, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeIntersect', () => {
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 2 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeBefore', () => {
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeBefore({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test('isInlineRangeAfter', () => {
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 2, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 2, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 1, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 1 })
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
isInlineRangeAfter({ index: 0, length: 1 }, { index: 0, length: 0 })
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdge', () => {
|
||||
expect(isInlineRangeEdge(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdge(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdge(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeBefore', () => {
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeBefore(0, { index: 0, length: 1 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isInlineRangeEdgeAfter', () => {
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 1 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 1, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(1, { index: 0, length: 0 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 1 })).toEqual(false);
|
||||
|
||||
expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
test('isPoint', () => {
|
||||
expect(isPoint({ index: 1, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 0, length: 2 })).toEqual(false);
|
||||
|
||||
expect(isPoint({ index: 0, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 0 })).toEqual(true);
|
||||
|
||||
expect(isPoint({ index: 2, length: 2 })).toEqual(false);
|
||||
});
|
||||
|
||||
test('mergeInlineRange', () => {
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 0 }, { index: 0, length: 0 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 0 }, { index: 2, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 2, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 1,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 3, length: 2 }, { index: 2, length: 1 })
|
||||
).toEqual({
|
||||
index: 2,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 4 }, { index: 1, length: 1 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 1, length: 1 }, { index: 0, length: 4 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeInlineRange({ index: 0, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({
|
||||
index: 0,
|
||||
length: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('intersectInlineRange', () => {
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual(null);
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 1, length: 1 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 2 }, { index: 2, length: 0 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 0 }, { index: 1, length: 0 })
|
||||
).toEqual({ index: 1, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 2 }, { index: 0, length: 3 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 1, length: 1 }, { index: 2, length: 2 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 2 }, { index: 1, length: 3 })
|
||||
).toEqual({ index: 2, length: 2 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 2, length: 1 }, { index: 1, length: 1 })
|
||||
).toEqual({ index: 2, length: 0 });
|
||||
|
||||
expect(
|
||||
intersectInlineRange({ index: 0, length: 4 }, { index: 1, length: 2 })
|
||||
).toEqual({ index: 1, length: 2 });
|
||||
});
|
||||
173
blocksuite/framework/std/src/__tests__/inline/utils.ts
Normal file
173
blocksuite/framework/std/src/__tests__/inline/utils.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import type { InlineEditor, InlineRange } from '../../inline/index.js';
|
||||
|
||||
const defaultPlaygroundURL = new URL(
|
||||
`http://localhost:${process.env.CI ? 4173 : 5173}/`
|
||||
);
|
||||
|
||||
export async function type(page: Page, content: string) {
|
||||
await page.keyboard.type(content, { delay: 50 });
|
||||
}
|
||||
|
||||
export async function press(page: Page, content: string) {
|
||||
await page.keyboard.press(content, { delay: 50 });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
export async function enterInlineEditorPlayground(page: Page) {
|
||||
const url = new URL('examples/inline/index.html', defaultPlaygroundURL);
|
||||
await page.goto(url.toString());
|
||||
}
|
||||
|
||||
export async function focusInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
(richTexts[index] as any).inlineEditor.focusEnd();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getDeltaFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<DeltaInsert> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.yText.toDelta();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function getInlineRangeFromInlineRichText(
|
||||
page: Page,
|
||||
index = 0
|
||||
): Promise<InlineRange | null> {
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(index => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index] as any).inlineEditor as InlineEditor;
|
||||
return editor.getInlineRange();
|
||||
}, index);
|
||||
}
|
||||
|
||||
export async function setInlineRichTextRange(
|
||||
page: Page,
|
||||
inlineRange: InlineRange,
|
||||
index = 0
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([inlineRange, index]) => {
|
||||
const richTexts = document
|
||||
.querySelector('test-page')
|
||||
?.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[index as number] as any)
|
||||
.inlineEditor as InlineEditor;
|
||||
editor.setInlineRange(inlineRange as InlineRange);
|
||||
},
|
||||
[inlineRange, index]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRichTextLine(
|
||||
page: Page,
|
||||
index: number,
|
||||
i = 0
|
||||
): Promise<readonly [string, number]> {
|
||||
return page.evaluate(
|
||||
([index, i]) => {
|
||||
const richTexts = document.querySelectorAll('test-rich-text');
|
||||
|
||||
if (!richTexts) {
|
||||
throw new Error('Cannot find test-rich-text');
|
||||
}
|
||||
|
||||
const editor = (richTexts[i] as any).inlineEditor as InlineEditor;
|
||||
const result = editor.getLine(index);
|
||||
if (!result) {
|
||||
throw new Error('Cannot find line');
|
||||
}
|
||||
const { line, rangeIndexRelatedToLine } = result;
|
||||
return [line.vTextContent, rangeIndexRelatedToLine] as const;
|
||||
},
|
||||
[index, i]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInlineRangeIndexRect(
|
||||
page: Page,
|
||||
[richTextIndex, inlineIndex]: [number, number],
|
||||
coordOffSet: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
const rect = await page.evaluate(
|
||||
({ richTextIndex, inlineIndex: vIndex, coordOffSet }) => {
|
||||
const richText = document.querySelectorAll('test-rich-text')[
|
||||
richTextIndex
|
||||
] as any;
|
||||
const domRange = richText.inlineEditor.toDomRange({
|
||||
index: vIndex,
|
||||
length: 0,
|
||||
});
|
||||
const pointBound = domRange.getBoundingClientRect();
|
||||
return {
|
||||
x: pointBound.left + coordOffSet.x,
|
||||
y: pointBound.top + pointBound.height / 2 + coordOffSet.y,
|
||||
};
|
||||
},
|
||||
{
|
||||
richTextIndex,
|
||||
inlineIndex,
|
||||
coordOffSet,
|
||||
}
|
||||
);
|
||||
return rect;
|
||||
}
|
||||
|
||||
export async function assertSelection(
|
||||
page: Page,
|
||||
richTextIndex: number,
|
||||
rangeIndex: number,
|
||||
rangeLength = 0
|
||||
) {
|
||||
const actual = await page.evaluate(
|
||||
([richTextIndex]) => {
|
||||
const richText =
|
||||
document?.querySelectorAll('test-rich-text')[richTextIndex];
|
||||
// @ts-expect-error getInlineRange
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
return inlineEditor?.getInlineRange();
|
||||
},
|
||||
[richTextIndex]
|
||||
);
|
||||
expect(actual).toEqual({ index: rangeIndex, length: rangeLength });
|
||||
}
|
||||
41
blocksuite/framework/std/src/__tests__/test-block.ts
Normal file
41
blocksuite/framework/std/src/__tests__/test-block.ts
Normal file
@@ -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> `;
|
||||
}
|
||||
}
|
||||
38
blocksuite/framework/std/src/__tests__/test-editor.ts
Normal file
38
blocksuite/framework/std/src/__tests__/test-editor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { ExtensionType, Store } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.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({
|
||||
store: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html` <div class="test-editor-container">
|
||||
${this._std.render()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Store;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor specs: ExtensionType[] = [];
|
||||
}
|
||||
63
blocksuite/framework/std/src/__tests__/test-schema.ts
Normal file
63
blocksuite/framework/std/src/__tests__/test-schema.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
BlockModel,
|
||||
BlockSchemaExtension,
|
||||
defineBlockSchema,
|
||||
} 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 const RootBlockSchemaExtension = BlockSchemaExtension(RootBlockSchema);
|
||||
|
||||
export class RootBlockModel extends BlockModel<
|
||||
ReturnType<(typeof RootBlockSchema)['model']['props']>
|
||||
> {}
|
||||
|
||||
export const NoteBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:note',
|
||||
props: () => ({}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'hub',
|
||||
parent: ['test:page'],
|
||||
children: ['test:heading'],
|
||||
},
|
||||
});
|
||||
|
||||
export const NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema);
|
||||
|
||||
export class NoteBlockModel extends BlockModel<
|
||||
ReturnType<(typeof NoteBlockSchema)['model']['props']>
|
||||
> {}
|
||||
|
||||
export const HeadingBlockSchema = defineBlockSchema({
|
||||
flavour: 'test:heading',
|
||||
props: internal => ({
|
||||
type: 'h1',
|
||||
text: internal.Text(),
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
role: 'content',
|
||||
parent: ['test:note'],
|
||||
},
|
||||
});
|
||||
|
||||
export const HeadingBlockSchemaExtension =
|
||||
BlockSchemaExtension(HeadingBlockSchema);
|
||||
|
||||
export class HeadingBlockModel extends BlockModel<
|
||||
ReturnType<(typeof HeadingBlockSchema)['model']['props']>
|
||||
> {}
|
||||
23
blocksuite/framework/std/src/__tests__/test-spec.ts
Normal file
23
blocksuite/framework/std/src/__tests__/test-spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import './test-block.js';
|
||||
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { BlockViewExtension } 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).props.type$.value;
|
||||
|
||||
if (h === 'h1') {
|
||||
return literal`test-h1-block`;
|
||||
}
|
||||
|
||||
return literal`test-h2-block`;
|
||||
}),
|
||||
];
|
||||
33
blocksuite/framework/std/src/clipboard/clipboard-adapter.ts
Normal file
33
blocksuite/framework/std/src/clipboard/clipboard-adapter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
|
||||
import type {
|
||||
BaseAdapter,
|
||||
ExtensionType,
|
||||
Transformer,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
type AdapterConstructor = new (
|
||||
job: Transformer,
|
||||
provider: ServiceProvider
|
||||
) => BaseAdapter;
|
||||
|
||||
export interface ClipboardAdapterConfig {
|
||||
mimeType: string;
|
||||
priority: number;
|
||||
adapter: AdapterConstructor;
|
||||
}
|
||||
|
||||
export const ClipboardAdapterConfigIdentifier =
|
||||
createIdentifier<ClipboardAdapterConfig>('clipboard-adapter-config');
|
||||
|
||||
export function ClipboardAdapterConfigExtension(
|
||||
config: ClipboardAdapterConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(
|
||||
ClipboardAdapterConfigIdentifier(config.mimeType),
|
||||
() => config
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
311
blocksuite/framework/std/src/clipboard/clipboard.ts
Normal file
311
blocksuite/framework/std/src/clipboard/clipboard.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type {
|
||||
BlockSnapshot,
|
||||
Slice,
|
||||
Store,
|
||||
TransformerMiddleware,
|
||||
} from '@blocksuite/store';
|
||||
import DOMPurify from 'dompurify';
|
||||
import * as lz from 'lz-string';
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
|
||||
import { onlyContainImgElement } from './utils.js';
|
||||
|
||||
export class Clipboard extends LifeCycleWatcher {
|
||||
static override readonly key = 'clipboard';
|
||||
|
||||
private get _adapters() {
|
||||
const adapterConfigs = this.std.provider.getAll(
|
||||
ClipboardAdapterConfigIdentifier
|
||||
);
|
||||
return Array.from(adapterConfigs.values());
|
||||
}
|
||||
|
||||
// Need to be cloned to a map for later use
|
||||
private readonly _getDataByType = (clipboardData: DataTransfer) => {
|
||||
const data = new Map<string, string | File[]>();
|
||||
for (const type of clipboardData.types) {
|
||||
if (type === 'Files') {
|
||||
data.set(type, Array.from(clipboardData.files));
|
||||
} else {
|
||||
data.set(type, clipboardData.getData(type));
|
||||
}
|
||||
}
|
||||
if (data.get('Files') && data.get('text/html')) {
|
||||
const htmlAst = unified()
|
||||
.use(rehypeParse)
|
||||
.parse(data.get('text/html') as string);
|
||||
|
||||
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';
|
||||
|
||||
if (isImgOnly) {
|
||||
data.delete('text/html');
|
||||
}
|
||||
}
|
||||
return (type: string) => {
|
||||
const item = data.get(type);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
const files = (data.get('Files') ?? []) as File[];
|
||||
if (files.length > 0) {
|
||||
return files;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _getSnapshotByPriority = async (
|
||||
getItem: (type: string) => string | File[],
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const byPriority = Array.from(this._adapters).sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
);
|
||||
for (const { adapter, mimeType } of byPriority) {
|
||||
const item = getItem(mimeType);
|
||||
if (Array.isArray(item)) {
|
||||
if (item.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
// if all files are not the same target type, fallback to */*
|
||||
!item
|
||||
.map(f => f.type === mimeType || mimeType === '*/*')
|
||||
.reduce((a, b) => a && b, true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (item) {
|
||||
const job = this._getJob();
|
||||
const adapterInstance = new adapter(job, this.std.provider);
|
||||
const payload = {
|
||||
file: item,
|
||||
assets: job.assetsManager,
|
||||
workspaceId: doc.workspace.id,
|
||||
pageId: doc.id,
|
||||
};
|
||||
const result = await adapterInstance.toSlice(
|
||||
payload,
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private _jobMiddlewares: TransformerMiddleware[] = [];
|
||||
|
||||
copy = async (slice: Slice) => {
|
||||
return this.copySlice(slice);
|
||||
};
|
||||
|
||||
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
|
||||
copySlice = async (slice: Slice) => {
|
||||
const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
|
||||
|
||||
await this.writeToClipboard(async _items => {
|
||||
const items = { ..._items };
|
||||
|
||||
await Promise.all(
|
||||
adapterKeys.map(async type => {
|
||||
const item = await this._getClipboardItem(slice, type);
|
||||
if (typeof item === 'string') {
|
||||
items[type] = item;
|
||||
}
|
||||
})
|
||||
);
|
||||
return items;
|
||||
});
|
||||
};
|
||||
|
||||
duplicateSlice = async (
|
||||
slice: Slice,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number,
|
||||
type = 'BLOCKSUITE/SNAPSHOT'
|
||||
) => {
|
||||
const items = {
|
||||
[type]: await this._getClipboardItem(slice, type),
|
||||
};
|
||||
|
||||
await this._getSnapshotByPriority(
|
||||
type => (items[type] as string | File[]) ?? '',
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
};
|
||||
|
||||
paste = async (
|
||||
event: ClipboardEvent,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const data = event.clipboardData;
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const json = this.readFromClipboard(data);
|
||||
const slice = await this._getSnapshotByPriority(
|
||||
type => json[type],
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
if (!slice) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerError,
|
||||
'No snapshot found'
|
||||
);
|
||||
}
|
||||
return slice;
|
||||
} catch {
|
||||
const getDataByType = this._getDataByType(data);
|
||||
const slice = await this._getSnapshotByPriority(
|
||||
type => getDataByType(type),
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
return slice;
|
||||
}
|
||||
};
|
||||
|
||||
pasteBlockSnapshot = async (
|
||||
snapshot: BlockSnapshot,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
|
||||
};
|
||||
|
||||
unuse = (middleware: TransformerMiddleware) => {
|
||||
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
|
||||
};
|
||||
|
||||
use = (middleware: TransformerMiddleware) => {
|
||||
this._jobMiddlewares.push(middleware);
|
||||
};
|
||||
|
||||
get configs() {
|
||||
return this._getJob().adapterConfigs;
|
||||
}
|
||||
|
||||
private async _getClipboardItem(slice: Slice, type: string) {
|
||||
const job = this._getJob();
|
||||
const adapterItem = this.std.getOptional(
|
||||
ClipboardAdapterConfigIdentifier(type)
|
||||
);
|
||||
if (!adapterItem) {
|
||||
return;
|
||||
}
|
||||
const { adapter } = adapterItem;
|
||||
const adapterInstance = new adapter(job, this.std.provider);
|
||||
const result = await adapterInstance.fromSlice(slice);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
return result.file;
|
||||
}
|
||||
|
||||
private _getJob() {
|
||||
return this.std.store.getTransformer(this._jobMiddlewares);
|
||||
}
|
||||
|
||||
readFromClipboard(clipboardData: DataTransfer) {
|
||||
const items = clipboardData.getData('text/html');
|
||||
const sanitizedItems = DOMPurify.sanitize(items);
|
||||
const domParser = new DOMParser();
|
||||
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
|
||||
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
|
||||
if (!dom) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerError,
|
||||
'No snapshot found'
|
||||
);
|
||||
}
|
||||
const json = JSON.parse(
|
||||
lz.decompressFromEncodedURIComponent(
|
||||
dom.dataset.blocksuiteSnapshot as string
|
||||
)
|
||||
);
|
||||
return json;
|
||||
}
|
||||
|
||||
sliceToSnapshot(slice: Slice) {
|
||||
const job = this._getJob();
|
||||
return job.sliceToSnapshot(slice);
|
||||
}
|
||||
|
||||
async writeToClipboard(
|
||||
updateItems: (
|
||||
items: Record<string, unknown>
|
||||
) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||
) {
|
||||
const _items = {
|
||||
'text/plain': '',
|
||||
'text/html': '',
|
||||
'image/png': '',
|
||||
};
|
||||
|
||||
const items = await updateItems(_items);
|
||||
|
||||
const text = items['text/plain'] as string;
|
||||
const innerHTML = items['text/html'] as string;
|
||||
const png = items['image/png'] as string | Blob;
|
||||
|
||||
delete items['text/plain'];
|
||||
delete items['text/html'];
|
||||
delete items['image/png'];
|
||||
|
||||
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
||||
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
|
||||
const htmlBlob = new Blob([html], {
|
||||
type: 'text/html',
|
||||
});
|
||||
const clipboardItems: Record<string, Blob> = {
|
||||
'text/html': htmlBlob,
|
||||
};
|
||||
if (text.length > 0) {
|
||||
const textBlob = new Blob([text], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
clipboardItems['text/plain'] = textBlob;
|
||||
}
|
||||
|
||||
if (png instanceof Blob) {
|
||||
clipboardItems['image/png'] = png;
|
||||
} else if (png.length > 0) {
|
||||
const pngBlob = new Blob([png], {
|
||||
type: 'image/png',
|
||||
});
|
||||
clipboardItems['image/png'] = pngBlob;
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
|
||||
}
|
||||
}
|
||||
2
blocksuite/framework/std/src/clipboard/index.ts
Normal file
2
blocksuite/framework/std/src/clipboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './clipboard';
|
||||
export * from './clipboard-adapter';
|
||||
33
blocksuite/framework/std/src/clipboard/utils.ts
Normal file
33
blocksuite/framework/std/src/clipboard/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RootContentMap } from 'hast';
|
||||
|
||||
type HastUnionType<
|
||||
K extends keyof RootContentMap,
|
||||
V extends RootContentMap[K],
|
||||
> = V;
|
||||
|
||||
export function onlyContainImgElement(
|
||||
ast: HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
|
||||
): 'yes' | 'no' | 'maybe' {
|
||||
if (ast.type === 'element') {
|
||||
switch (ast.tagName) {
|
||||
case 'html':
|
||||
case 'body':
|
||||
return ast.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||
if (a === 'no' || b === 'no') {
|
||||
return 'no';
|
||||
}
|
||||
if (a === 'maybe' && b === 'maybe') {
|
||||
return 'maybe';
|
||||
}
|
||||
return 'yes';
|
||||
}, 'maybe');
|
||||
case 'img':
|
||||
return 'yes';
|
||||
case 'head':
|
||||
return 'maybe';
|
||||
default:
|
||||
return 'no';
|
||||
}
|
||||
}
|
||||
return 'maybe';
|
||||
}
|
||||
1
blocksuite/framework/std/src/command/consts.ts
Normal file
1
blocksuite/framework/std/src/command/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const cmdSymbol = Symbol('cmds');
|
||||
3
blocksuite/framework/std/src/command/index.ts
Normal file
3
blocksuite/framework/std/src/command/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './consts.js';
|
||||
export * from './manager.js';
|
||||
export * from './types.js';
|
||||
237
blocksuite/framework/std/src/command/manager.ts
Normal file
237
blocksuite/framework/std/src/command/manager.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { cmdSymbol } from './consts.js';
|
||||
import type { Chain, Command, InitCommandCtx } from './types.js';
|
||||
|
||||
/**
|
||||
* Command manager to manage all commands
|
||||
*
|
||||
* Commands are functions that take a context and a next function as arguments
|
||||
*
|
||||
* ```ts
|
||||
* const myCommand: Command<input, output> = (ctx, next) => {
|
||||
* const count = ctx.count || 0;
|
||||
*
|
||||
* const success = someOperation();
|
||||
* if (success) {
|
||||
* return next({ count: count + 1 });
|
||||
* }
|
||||
* // if the command is not successful, you can return without calling next
|
||||
* return;
|
||||
* ```
|
||||
*
|
||||
* Command input and output data can be defined in the `Command` type
|
||||
*
|
||||
* ```ts
|
||||
* // input: ctx.firstName, ctx.lastName
|
||||
* // output: ctx.fullName
|
||||
* const myCommand: Command<{ firstName: string; lastName: string }, { fullName: string }> = (ctx, next) => {
|
||||
* const { firstName, lastName } = ctx;
|
||||
* const fullName = `${firstName} ${lastName}`;
|
||||
* return next({ fullName });
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Commands can be run in two ways:
|
||||
*
|
||||
* 1. Using `exec` method
|
||||
* `exec` is used to run a single command
|
||||
* ```ts
|
||||
* const [result, data] = commandManager.exec(myCommand, payload);
|
||||
* ```
|
||||
*
|
||||
* 2. Using `chain` method
|
||||
* `chain` is used to run a series of commands
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .pipe(myCommand1)
|
||||
* .pipe(myCommand2, payload)
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* Command chains will stop running if a command is not successful
|
||||
*
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .chain(myCommand1) <-- if this fail
|
||||
* .chain(myCommand2, payload) <- this won't run
|
||||
* .run();
|
||||
*
|
||||
* result <- result will be `false`
|
||||
* ```
|
||||
*
|
||||
* You can use `try` to run a series of commands and if one of them is successful, it will continue to the next command
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .try(chain => [
|
||||
* chain.pipe(myCommand1), <- if this fail
|
||||
* chain.pipe(myCommand2, payload), <- this will run, if this success
|
||||
* chain.pipe(myCommand3), <- this won't run
|
||||
* ])
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
* The `tryAll` method is similar to `try`, but it will run all commands even if one of them is successful
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .try(chain => [
|
||||
* chain.pipe(myCommand1), <- if this success
|
||||
* chain.pipe(myCommand2), <- this will also run
|
||||
* chain.pipe(myCommand3), <- so will this
|
||||
* ])
|
||||
* .run();
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
export class CommandManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'commandManager';
|
||||
|
||||
private readonly _createChain = (_cmds: Command[]): Chain => {
|
||||
const getCommandCtx = this._getCommandCtx;
|
||||
const createChain = this._createChain;
|
||||
const chain = this.chain;
|
||||
|
||||
return {
|
||||
[cmdSymbol]: _cmds,
|
||||
run: function (this: Chain) {
|
||||
let ctx = getCommandCtx();
|
||||
let success = false;
|
||||
try {
|
||||
const cmds = this[cmdSymbol];
|
||||
ctx = runCmds(ctx, [
|
||||
...cmds,
|
||||
(_, next) => {
|
||||
success = true;
|
||||
next();
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return [success, ctx];
|
||||
},
|
||||
with: function (this: Chain, value) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([...cmds, (_, next) => next(value)]) as never;
|
||||
},
|
||||
pipe: function (this: Chain, command: Command, input?: object) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(ctx, next) => command({ ...ctx, ...input }, next),
|
||||
]);
|
||||
},
|
||||
try: function (this: Chain, fn) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(beforeCtx, next) => {
|
||||
let ctx = beforeCtx;
|
||||
|
||||
const commands = fn(chain());
|
||||
|
||||
commands.some(innerChain => {
|
||||
innerChain[cmdSymbol] = [
|
||||
(_, next) => {
|
||||
next(ctx);
|
||||
},
|
||||
...innerChain[cmdSymbol],
|
||||
];
|
||||
|
||||
const [success, branchCtx] = innerChain.run();
|
||||
ctx = { ...ctx, ...branchCtx };
|
||||
|
||||
if (success) {
|
||||
next(ctx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
]) as never;
|
||||
},
|
||||
tryAll: function (this: Chain, fn) {
|
||||
const cmds = this[cmdSymbol];
|
||||
return createChain([
|
||||
...cmds,
|
||||
(beforeCtx, next) => {
|
||||
let ctx = beforeCtx;
|
||||
|
||||
let allFail = true;
|
||||
|
||||
const commands = fn(chain());
|
||||
|
||||
commands.forEach(innerChain => {
|
||||
innerChain[cmdSymbol] = [
|
||||
(_, next) => {
|
||||
next(ctx);
|
||||
},
|
||||
...innerChain[cmdSymbol],
|
||||
];
|
||||
|
||||
const [success, branchCtx] = innerChain.run();
|
||||
ctx = { ...ctx, ...branchCtx };
|
||||
|
||||
if (success) {
|
||||
allFail = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!allFail) {
|
||||
next(ctx);
|
||||
}
|
||||
},
|
||||
]) as never;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _getCommandCtx = (): InitCommandCtx => {
|
||||
return {
|
||||
std: this.std,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a chain to run a series of commands
|
||||
* ```ts
|
||||
* const chain = commandManager.chain();
|
||||
* const [result, data] = chain
|
||||
* .myCommand1()
|
||||
* .myCommand2(payload)
|
||||
* .run();
|
||||
* ```
|
||||
* @returns [success, data] - success is a boolean to indicate if the chain is successful,
|
||||
* data is the final context after running the chain
|
||||
*/
|
||||
chain = (): Chain<InitCommandCtx> => {
|
||||
return this._createChain([]);
|
||||
};
|
||||
|
||||
exec = <Output extends object, Input extends object>(
|
||||
command: Command<Input, Output>,
|
||||
input?: Input
|
||||
) => {
|
||||
return this.chain().pipe(command, input).run();
|
||||
};
|
||||
}
|
||||
|
||||
function runCmds(ctx: InitCommandCtx, [cmd, ...rest]: Command[]) {
|
||||
let _ctx = ctx;
|
||||
if (cmd) {
|
||||
cmd(ctx, data => {
|
||||
_ctx = runCmds({ ...ctx, ...data }, rest);
|
||||
});
|
||||
}
|
||||
return _ctx;
|
||||
}
|
||||
42
blocksuite/framework/std/src/command/types.ts
Normal file
42
blocksuite/framework/std/src/command/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// type A = {};
|
||||
// type B = { prop?: string };
|
||||
// type C = { prop: string };
|
||||
// type TestA = MakeOptionalIfEmpty<A>; // void | {}
|
||||
// type TestB = MakeOptionalIfEmpty<B>; // void | { prop?: string }
|
||||
// type TestC = MakeOptionalIfEmpty<C>; // { prop: string }
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import type { cmdSymbol } from './consts.js';
|
||||
|
||||
export interface InitCommandCtx {
|
||||
std: BlockStdScope;
|
||||
}
|
||||
|
||||
export type Cmds = {
|
||||
[cmdSymbol]: Command[];
|
||||
};
|
||||
|
||||
export type Command<Input = InitCommandCtx, Output = {}> = (
|
||||
input: Input & InitCommandCtx,
|
||||
next: (output?: Output) => void
|
||||
) => void;
|
||||
|
||||
export type Chain<CommandCtx extends object = InitCommandCtx> = {
|
||||
[cmdSymbol]: Command[];
|
||||
with: <Out extends object>(input: Out) => Chain<CommandCtx & Out>;
|
||||
pipe: {
|
||||
<Out extends object>(
|
||||
command: Command<CommandCtx, Out>
|
||||
): Chain<CommandCtx & Out>;
|
||||
<Out extends object, In extends object>(
|
||||
command: Command<In, Out>,
|
||||
input?: In
|
||||
): Chain<CommandCtx & In & Out>;
|
||||
};
|
||||
try: <Out extends object>(
|
||||
commands: (chain: Chain<CommandCtx>) => Chain<CommandCtx & Out>[]
|
||||
) => Chain<CommandCtx & Out>;
|
||||
tryAll: <Out extends object>(
|
||||
commands: (chain: Chain<CommandCtx>) => Chain<CommandCtx & Out>[]
|
||||
) => Chain<CommandCtx & Out>;
|
||||
run: () => [false, Partial<CommandCtx> & InitCommandCtx] | [true, CommandCtx];
|
||||
};
|
||||
14
blocksuite/framework/std/src/effects.ts
Normal file
14
blocksuite/framework/std/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { GfxViewportElement } from './gfx/viewport-element.js';
|
||||
import { VElement, VLine, VText } from './inline/index.js';
|
||||
import { EditorHost } from './view/index.js';
|
||||
|
||||
export function effects() {
|
||||
// editor host
|
||||
customElements.define('editor-host', EditorHost);
|
||||
// gfx
|
||||
customElements.define('gfx-viewport', GfxViewportElement);
|
||||
// inline
|
||||
customElements.define('v-element', VElement);
|
||||
customElements.define('v-line', VLine);
|
||||
customElements.define('v-text', VText);
|
||||
}
|
||||
62
blocksuite/framework/std/src/event/base.ts
Normal file
62
blocksuite/framework/std/src/event/base.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
type MatchEvent<T extends string> = T extends UIEventStateType
|
||||
? BlockSuiteUIEventState[T]
|
||||
: UIEventState;
|
||||
|
||||
export class UIEventState {
|
||||
/** when extends, override it with pattern `xxxState` */
|
||||
type = 'defaultState';
|
||||
|
||||
constructor(public event: Event) {}
|
||||
}
|
||||
|
||||
export class UIEventStateContext {
|
||||
private _map: Record<string, UIEventState> = {};
|
||||
|
||||
add = <State extends UIEventState = UIEventState>(state: State) => {
|
||||
const name = state.type;
|
||||
if (this._map[name]) {
|
||||
console.warn('UIEventStateContext: state name duplicated', name);
|
||||
}
|
||||
|
||||
this._map[name] = state;
|
||||
};
|
||||
|
||||
get = <Type extends UIEventStateType = UIEventStateType>(
|
||||
type: Type
|
||||
): MatchEvent<Type> => {
|
||||
const state = this._map[type];
|
||||
if (!state) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
`UIEventStateContext: state ${type} not found`
|
||||
);
|
||||
}
|
||||
return state as MatchEvent<Type>;
|
||||
};
|
||||
|
||||
has = (type: UIEventStateType) => {
|
||||
return !!this._map[type];
|
||||
};
|
||||
|
||||
static from(...states: UIEventState[]) {
|
||||
const context = new UIEventStateContext();
|
||||
states.forEach(state => {
|
||||
context.add(state);
|
||||
});
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
export type UIEventHandler = (
|
||||
context: UIEventStateContext
|
||||
) => boolean | null | undefined | void;
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
defaultState: UIEventState;
|
||||
}
|
||||
|
||||
type UIEventStateType = keyof BlockSuiteUIEventState;
|
||||
}
|
||||
56
blocksuite/framework/std/src/event/control/clipboard.ts
Normal file
56
blocksuite/framework/std/src/event/control/clipboard.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type { UIEventDispatcher } from '../dispatcher.js';
|
||||
import { ClipboardEventState } from '../state/clipboard.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class ClipboardControl {
|
||||
private readonly _copy = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'copy',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _cut = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'cut',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _paste = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'paste',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _createContext(event: Event, clipboardState: ClipboardEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
}),
|
||||
clipboardState
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._dispatcher.disposables.addFromEvent(document, 'cut', this._cut);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'copy', this._copy);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'paste', this._paste);
|
||||
}
|
||||
}
|
||||
129
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
129
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_ANDROID, IS_MAC } from '@blocksuite/global/env';
|
||||
|
||||
import {
|
||||
type UIEventHandler,
|
||||
UIEventState,
|
||||
UIEventStateContext,
|
||||
} from '../base.js';
|
||||
import type { EventOptions, UIEventDispatcher } from '../dispatcher.js';
|
||||
import { androidBindKeymapPatch, bindKeymap } from '../keymap.js';
|
||||
import { KeyboardEventState } from '../state/index.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class KeyboardControl {
|
||||
private readonly _down = (event: KeyboardEvent) => {
|
||||
if (!this._shouldTrigger(event)) {
|
||||
return;
|
||||
}
|
||||
const keyboardEventState = new KeyboardEventState({
|
||||
event,
|
||||
composing: this.composition,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'keyDown',
|
||||
this._createContext(event, keyboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _shouldTrigger = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return false;
|
||||
}
|
||||
const mod = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
if (
|
||||
['c', 'v', 'x'].includes(event.key) &&
|
||||
mod &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private readonly _up = (event: KeyboardEvent) => {
|
||||
if (!this._shouldTrigger(event)) {
|
||||
return;
|
||||
}
|
||||
const keyboardEventState = new KeyboardEventState({
|
||||
event,
|
||||
composing: this.composition,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'keyUp',
|
||||
this._createContext(event, keyboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private composition = false;
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _createContext(event: Event, keyboardState: KeyboardEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
}),
|
||||
keyboardState
|
||||
);
|
||||
}
|
||||
|
||||
bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) {
|
||||
const disposables = new DisposableGroup();
|
||||
if (IS_ANDROID) {
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'beforeInput',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = androidBindKeymapPatch(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'keyDown',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = bindKeymap(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
return () => disposables.dispose();
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._dispatcher.disposables.addFromEvent(document, 'keydown', this._down);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'keyup', this._up);
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this.composition = true;
|
||||
},
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'compositionend',
|
||||
() => {
|
||||
this.composition = false;
|
||||
},
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { IS_IPAD } from '@blocksuite/global/env';
|
||||
import { Vec } from '@blocksuite/global/gfx';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type { UIEventDispatcher } from '../dispatcher.js';
|
||||
import {
|
||||
DndEventState,
|
||||
MultiPointerEventState,
|
||||
PointerEventState,
|
||||
} from '../state/index.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
import { isFarEnough } from '../utils.js';
|
||||
|
||||
type PointerId = typeof PointerEvent.prototype.pointerId;
|
||||
|
||||
function createContext(
|
||||
event: Event,
|
||||
state: PointerEventState | MultiPointerEventState
|
||||
) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Target,
|
||||
}),
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 1000;
|
||||
|
||||
abstract class PointerControllerBase {
|
||||
constructor(
|
||||
protected _dispatcher: UIEventDispatcher,
|
||||
protected _getRect: () => DOMRect
|
||||
) {}
|
||||
|
||||
abstract listen(): void;
|
||||
}
|
||||
|
||||
class PointerEventForward extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const pointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
this._startStates.set(pointerId, pointerState);
|
||||
this._lastStates.set(pointerId, pointerState);
|
||||
this._dispatcher.run('pointerDown', createContext(event, pointerState));
|
||||
};
|
||||
|
||||
private readonly _lastStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const start = this._startStates.get(pointerId) ?? null;
|
||||
const last = this._lastStates.get(pointerId) ?? null;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start?.x ?? -Infinity,
|
||||
startY: start?.y ?? -Infinity,
|
||||
last,
|
||||
});
|
||||
this._lastStates.set(pointerId, state);
|
||||
|
||||
this._dispatcher.run('pointerMove', createContext(event, state));
|
||||
};
|
||||
|
||||
private readonly _startStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _upOrOut = (up: boolean) => (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
|
||||
const start = this._startStates.get(pointerId) ?? null;
|
||||
const last = this._lastStates.get(pointerId) ?? null;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start?.x ?? -Infinity,
|
||||
startY: start?.y ?? -Infinity,
|
||||
last,
|
||||
});
|
||||
|
||||
this._startStates.delete(pointerId);
|
||||
this._lastStates.delete(pointerId);
|
||||
|
||||
this._dispatcher.run(
|
||||
up ? 'pointerUp' : 'pointerOut',
|
||||
createContext(event, state)
|
||||
);
|
||||
};
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointermove', this._move);
|
||||
disposables.addFromEvent(host, 'pointerup', this._upOrOut(true));
|
||||
disposables.addFromEvent(host, 'pointerout', this._upOrOut(false));
|
||||
}
|
||||
}
|
||||
|
||||
class ClickController extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
// disable for secondary pointer
|
||||
if (event.isPrimary === false) return;
|
||||
|
||||
if (
|
||||
this._downPointerState &&
|
||||
event.pointerId === this._downPointerState.raw.pointerId &&
|
||||
event.timeStamp - this._downPointerState.raw.timeStamp < 500 &&
|
||||
!isFarEnough(event, this._downPointerState.raw)
|
||||
) {
|
||||
this._pointerDownCount++;
|
||||
} else {
|
||||
this._pointerDownCount = 1;
|
||||
}
|
||||
|
||||
this._downPointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
};
|
||||
|
||||
private _downPointerState: PointerEventState | null = null;
|
||||
|
||||
private _pointerDownCount = 0;
|
||||
|
||||
private readonly _up = (event: PointerEvent) => {
|
||||
if (!this._downPointerState) return;
|
||||
|
||||
if (isFarEnough(this._downPointerState.raw, event)) {
|
||||
this._pointerDownCount = 0;
|
||||
this._downPointerState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
const context = createContext(event, state);
|
||||
|
||||
const run = () => {
|
||||
this._dispatcher.run('click', context);
|
||||
if (this._pointerDownCount === 2) {
|
||||
this._dispatcher.run('doubleClick', context);
|
||||
}
|
||||
if (this._pointerDownCount === 3) {
|
||||
this._dispatcher.run('tripleClick', context);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
};
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointerup', this._up);
|
||||
}
|
||||
}
|
||||
|
||||
class DragController extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
if (this._nativeDragging) return;
|
||||
|
||||
if (!event.isPrimary) {
|
||||
if (this._dragging && this._lastPointerState) {
|
||||
this._up(this._lastPointerState.raw);
|
||||
}
|
||||
this._reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
this._startPointerState = pointerState;
|
||||
|
||||
this._dispatcher.disposables.addFromEvent(
|
||||
document,
|
||||
'pointermove',
|
||||
this._move
|
||||
);
|
||||
this._dispatcher.disposables.addFromEvent(document, 'pointerup', this._up);
|
||||
};
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _lastPointerState: PointerEventState | null = null;
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
if (
|
||||
this._startPointerState === null ||
|
||||
this._startPointerState.raw.pointerId !== event.pointerId
|
||||
)
|
||||
return;
|
||||
|
||||
const start = this._startPointerState;
|
||||
const last = this._lastPointerState ?? start;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start.x,
|
||||
startY: start.y,
|
||||
last,
|
||||
});
|
||||
|
||||
this._lastPointerState = state;
|
||||
|
||||
if (
|
||||
!this._nativeDragging &&
|
||||
!this._dragging &&
|
||||
isFarEnough(event, this._startPointerState.raw)
|
||||
) {
|
||||
this._dragging = true;
|
||||
this._dispatcher.run('dragStart', createContext(event, start));
|
||||
}
|
||||
|
||||
if (this._dragging) {
|
||||
this._dispatcher.run('dragMove', createContext(event, state));
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _nativeDragEnd = (event: DragEvent) => {
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragEnd',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private _nativeDragging = false;
|
||||
|
||||
private readonly _nativeDragMove = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragMove',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragStart = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = true;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragStart',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragOver = (event: DragEvent) => {
|
||||
// prevent default to allow drop in editor
|
||||
event.preventDefault();
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragOver',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragLeave = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragLeave',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDrop = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDrop',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _reset = () => {
|
||||
this._dragging = false;
|
||||
this._startPointerState = null;
|
||||
this._lastPointerState = null;
|
||||
|
||||
document.removeEventListener('pointermove', this._move);
|
||||
document.removeEventListener('pointerup', this._up);
|
||||
};
|
||||
|
||||
private _startPointerState: PointerEventState | null = null;
|
||||
|
||||
private readonly _up = (event: PointerEvent) => {
|
||||
if (
|
||||
!this._startPointerState ||
|
||||
this._startPointerState.raw.pointerId !== event.pointerId
|
||||
)
|
||||
return;
|
||||
|
||||
const start = this._startPointerState;
|
||||
const last = this._lastPointerState;
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: start.x,
|
||||
startY: start.y,
|
||||
last,
|
||||
});
|
||||
|
||||
if (this._dragging) {
|
||||
this._dispatcher.run('dragEnd', createContext(event, state));
|
||||
}
|
||||
|
||||
this._reset();
|
||||
};
|
||||
|
||||
// https://mikepk.com/2020/10/iOS-safari-scribble-bug/
|
||||
private _applyScribblePatch() {
|
||||
if (!IS_IPAD) return;
|
||||
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'touchmove', (event: TouchEvent) => {
|
||||
if (
|
||||
this._dragging &&
|
||||
this._startPointerState &&
|
||||
this._startPointerState.raw.pointerType === 'pen'
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _createContext(event: Event, dndState: DndEventState) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Target,
|
||||
}),
|
||||
dndState
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
this._applyScribblePatch();
|
||||
|
||||
disposables.add(
|
||||
host.std.dnd.monitor({
|
||||
onDragStart: () => {
|
||||
this._nativeDragging = true;
|
||||
},
|
||||
onDrop: () => {
|
||||
this._nativeDragging = false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
disposables.addFromEvent(host, 'dragstart', this._nativeDragStart);
|
||||
disposables.addFromEvent(host, 'dragend', this._nativeDragEnd);
|
||||
disposables.addFromEvent(host, 'drag', this._nativeDragMove);
|
||||
disposables.addFromEvent(host, 'drop', this._nativeDrop);
|
||||
disposables.addFromEvent(host, 'dragover', this._nativeDragOver);
|
||||
disposables.addFromEvent(host, 'dragleave', this._nativeDragLeave);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DualDragControllerBase extends PointerControllerBase {
|
||||
private readonly _down = (event: PointerEvent) => {
|
||||
// Another pointer down
|
||||
if (
|
||||
this._startPointerStates.primary !== null &&
|
||||
this._startPointerStates.secondary !== null
|
||||
) {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
if (this._startPointerStates.primary === null && !event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: -Infinity,
|
||||
startY: -Infinity,
|
||||
last: null,
|
||||
});
|
||||
|
||||
if (event.isPrimary) {
|
||||
this._startPointerStates.primary = state;
|
||||
} else {
|
||||
this._startPointerStates.secondary = state;
|
||||
}
|
||||
};
|
||||
|
||||
private _lastPointerStates: {
|
||||
primary: PointerEventState | null;
|
||||
secondary: PointerEventState | null;
|
||||
} = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
|
||||
private readonly _move = (event: PointerEvent) => {
|
||||
if (
|
||||
this._startPointerStates.primary === null ||
|
||||
this._startPointerStates.secondary === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isPrimary } = event;
|
||||
const startPrimaryState = this._startPointerStates.primary;
|
||||
let lastPrimaryState = this._lastPointerStates.primary;
|
||||
|
||||
const startSecondaryState = this._startPointerStates.secondary;
|
||||
let lastSecondaryState = this._lastPointerStates.secondary;
|
||||
|
||||
if (isPrimary) {
|
||||
lastPrimaryState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: startPrimaryState.x,
|
||||
startY: startPrimaryState.y,
|
||||
last: lastPrimaryState,
|
||||
});
|
||||
} else {
|
||||
lastSecondaryState = new PointerEventState({
|
||||
event,
|
||||
rect: this._getRect(),
|
||||
startX: startSecondaryState.x,
|
||||
startY: startSecondaryState.y,
|
||||
last: lastSecondaryState,
|
||||
});
|
||||
}
|
||||
|
||||
const multiPointerState = new MultiPointerEventState(event, [
|
||||
lastPrimaryState ?? startPrimaryState,
|
||||
lastSecondaryState ?? startSecondaryState,
|
||||
]);
|
||||
|
||||
this._handleMove(event, multiPointerState);
|
||||
|
||||
this._lastPointerStates = {
|
||||
primary: lastPrimaryState,
|
||||
secondary: lastSecondaryState,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _reset = () => {
|
||||
this._startPointerStates = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
this._lastPointerStates = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
};
|
||||
|
||||
private _startPointerStates: {
|
||||
primary: PointerEventState | null;
|
||||
secondary: PointerEventState | null;
|
||||
} = {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
};
|
||||
|
||||
private readonly _upOrOut = (event: PointerEvent) => {
|
||||
const { pointerId } = event;
|
||||
if (
|
||||
pointerId === this._startPointerStates.primary?.raw.pointerId ||
|
||||
pointerId === this._startPointerStates.secondary?.raw.pointerId
|
||||
) {
|
||||
this._reset();
|
||||
}
|
||||
};
|
||||
|
||||
abstract _handleMove(
|
||||
event: PointerEvent,
|
||||
state: MultiPointerEventState
|
||||
): void;
|
||||
|
||||
override listen(): void {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(host, 'pointerdown', this._down);
|
||||
disposables.addFromEvent(host, 'pointermove', this._move);
|
||||
disposables.addFromEvent(host, 'pointerup', this._upOrOut);
|
||||
disposables.addFromEvent(host, 'pointerout', this._upOrOut);
|
||||
}
|
||||
}
|
||||
|
||||
class PinchController extends DualDragControllerBase {
|
||||
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
|
||||
const deltaFirstPointer = state.pointers[0].delta;
|
||||
const deltaSecondPointer = state.pointers[1].delta;
|
||||
|
||||
const deltaFirstPointerVec = Vec.toVec(deltaFirstPointer);
|
||||
const deltaSecondPointerVec = Vec.toVec(deltaSecondPointer);
|
||||
|
||||
const deltaFirstPointerValue = Vec.len(deltaFirstPointerVec);
|
||||
const deltaSecondPointerValue = Vec.len(deltaSecondPointerVec);
|
||||
|
||||
const deltaDotProduct = Vec.dpr(
|
||||
deltaFirstPointerVec,
|
||||
deltaSecondPointerVec
|
||||
);
|
||||
|
||||
const deltaValueThreshold = 0.1;
|
||||
|
||||
// the changes of distance between two pointers is not far enough
|
||||
if (
|
||||
!isFarEnough(deltaFirstPointer, deltaSecondPointer) ||
|
||||
deltaDotProduct > 0 ||
|
||||
deltaFirstPointerValue < deltaValueThreshold ||
|
||||
deltaSecondPointerValue < deltaValueThreshold
|
||||
)
|
||||
return;
|
||||
|
||||
this._dispatcher.run('pinch', createContext(event, state));
|
||||
}
|
||||
}
|
||||
|
||||
class PanController extends DualDragControllerBase {
|
||||
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
|
||||
const deltaFirstPointer = state.pointers[0].delta;
|
||||
const deltaSecondPointer = state.pointers[1].delta;
|
||||
|
||||
const deltaDotProduct = Vec.dpr(
|
||||
Vec.toVec(deltaFirstPointer),
|
||||
Vec.toVec(deltaSecondPointer)
|
||||
);
|
||||
|
||||
// the center move distance is not far enough
|
||||
if (
|
||||
!isFarEnough(deltaFirstPointer, deltaSecondPointer) &&
|
||||
deltaDotProduct < 0
|
||||
)
|
||||
return;
|
||||
|
||||
this._dispatcher.run('pan', createContext(event, state));
|
||||
}
|
||||
}
|
||||
|
||||
export class PointerControl {
|
||||
private _cachedRect: DOMRect | null = null;
|
||||
|
||||
private readonly _getRect = () => {
|
||||
if (this._cachedRect === null) {
|
||||
this._updateRect();
|
||||
}
|
||||
return this._cachedRect as DOMRect;
|
||||
};
|
||||
|
||||
// XXX: polling is used instead of MutationObserver
|
||||
// due to potential performance issues
|
||||
private _pollingInterval: number | null = null;
|
||||
|
||||
private readonly controllers: PointerControllerBase[];
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {
|
||||
this.controllers = [
|
||||
new PointerEventForward(_dispatcher, this._getRect),
|
||||
new ClickController(_dispatcher, this._getRect),
|
||||
new DragController(_dispatcher, this._getRect),
|
||||
new PanController(_dispatcher, this._getRect),
|
||||
new PinchController(_dispatcher, this._getRect),
|
||||
];
|
||||
}
|
||||
|
||||
private _startPolling() {
|
||||
const poll = () => {
|
||||
nextTick()
|
||||
.then(() => this._updateRect())
|
||||
.catch(console.error);
|
||||
};
|
||||
this._pollingInterval = window.setInterval(poll, POLL_INTERVAL);
|
||||
poll();
|
||||
}
|
||||
|
||||
protected _updateRect() {
|
||||
if (!this._dispatcher.host) return;
|
||||
this._cachedRect = this._dispatcher.host.getBoundingClientRect();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._pollingInterval !== null) {
|
||||
clearInterval(this._pollingInterval);
|
||||
this._pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._startPolling();
|
||||
this.controllers.forEach(controller => controller.listen());
|
||||
}
|
||||
}
|
||||
156
blocksuite/framework/std/src/event/control/range.ts
Normal file
156
blocksuite/framework/std/src/event/control/range.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { BlockComponent } from '../../view/index.js';
|
||||
import { UIEventState, UIEventStateContext } from '../base.js';
|
||||
import type {
|
||||
EventHandlerRunner,
|
||||
EventName,
|
||||
UIEventDispatcher,
|
||||
} from '../dispatcher.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class RangeControl {
|
||||
private readonly _buildScope = (eventName: EventName) => {
|
||||
let scope: EventHandlerRunner[] | undefined;
|
||||
const selection = document.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
scope = this._buildEventScopeByNativeRange(eventName, range);
|
||||
this._prev = range;
|
||||
} else if (this._prev !== null) {
|
||||
scope = this._buildEventScopeByNativeRange(eventName, this._prev);
|
||||
this._prev = null;
|
||||
}
|
||||
|
||||
return scope;
|
||||
};
|
||||
|
||||
private readonly _compositionEnd = (event: Event) => {
|
||||
const scope = this._buildScope('compositionEnd');
|
||||
|
||||
this._dispatcher.run('compositionEnd', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionStart = (event: Event) => {
|
||||
const scope = this._buildScope('compositionStart');
|
||||
|
||||
this._dispatcher.run('compositionStart', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionUpdate = (event: Event) => {
|
||||
const scope = this._buildScope('compositionUpdate');
|
||||
|
||||
this._dispatcher.run(
|
||||
'compositionUpdate',
|
||||
this._createContext(event),
|
||||
scope
|
||||
);
|
||||
};
|
||||
|
||||
private _prev: Range | null = null;
|
||||
|
||||
private readonly _selectionChange = (event: Event) => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (!selection.containsNode(this._dispatcher.host, true)) return;
|
||||
if (selection.containsNode(this._dispatcher.host)) return;
|
||||
|
||||
const scope = this._buildScope('selectionChange');
|
||||
|
||||
this._dispatcher.run('selectionChange', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
constructor(private readonly _dispatcher: UIEventDispatcher) {}
|
||||
|
||||
private _buildEventScopeByNativeRange(name: EventName, range: Range) {
|
||||
const blockIds = this._findBlockComponentPath(range);
|
||||
|
||||
return this._dispatcher.buildEventScope(name, blockIds);
|
||||
}
|
||||
|
||||
private _createContext(event: Event) {
|
||||
return UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _findBlockComponentPath(range: Range): string[] {
|
||||
const start = range.startContainer;
|
||||
const end = range.endContainer;
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
const getBlockView = (node: Node): BlockComponent | null => {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
// TODO(mirone/#6534): find a better way to get block element from a node
|
||||
return el?.closest<BlockComponent>('[data-block-id]') ?? null;
|
||||
};
|
||||
if (ancestor.nodeType === Node.TEXT_NODE) {
|
||||
const leaf = getBlockView(ancestor);
|
||||
if (leaf) {
|
||||
return [leaf.blockId];
|
||||
}
|
||||
}
|
||||
const nodes = new Set<Node>();
|
||||
|
||||
let startRecorded = false;
|
||||
const dfsDOMSearch = (current: Node | null, ancestor: Node) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (current === ancestor) {
|
||||
return;
|
||||
}
|
||||
if (current === end) {
|
||||
nodes.add(current);
|
||||
startRecorded = false;
|
||||
return;
|
||||
}
|
||||
if (current === start) {
|
||||
startRecorded = true;
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if (startRecorded) {
|
||||
if (
|
||||
current.nodeType === Node.TEXT_NODE ||
|
||||
current.nodeType === Node.ELEMENT_NODE
|
||||
) {
|
||||
nodes.add(current);
|
||||
}
|
||||
}
|
||||
dfsDOMSearch(current.firstChild, ancestor);
|
||||
dfsDOMSearch(current.nextSibling, ancestor);
|
||||
};
|
||||
dfsDOMSearch(ancestor.firstChild, ancestor);
|
||||
|
||||
const blocks = new Set<string>();
|
||||
nodes.forEach(node => {
|
||||
const blockView = getBlockView(node);
|
||||
if (!blockView) {
|
||||
return;
|
||||
}
|
||||
if (blocks.has(blockView.blockId)) {
|
||||
return;
|
||||
}
|
||||
blocks.add(blockView.blockId);
|
||||
});
|
||||
return Array.from(blocks);
|
||||
}
|
||||
|
||||
listen() {
|
||||
const { host, disposables } = this._dispatcher;
|
||||
disposables.addFromEvent(
|
||||
document,
|
||||
'selectionchange',
|
||||
this._selectionChange
|
||||
);
|
||||
disposables.addFromEvent(host, 'compositionstart', this._compositionStart);
|
||||
disposables.addFromEvent(host, 'compositionend', this._compositionEnd);
|
||||
disposables.addFromEvent(
|
||||
host,
|
||||
'compositionupdate',
|
||||
this._compositionUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
428
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
428
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
import { type BlockComponent, EditorHost } from '../view/index.js';
|
||||
import {
|
||||
type UIEventHandler,
|
||||
UIEventState,
|
||||
UIEventStateContext,
|
||||
} from './base.js';
|
||||
import { ClipboardControl } from './control/clipboard.js';
|
||||
import { KeyboardControl } from './control/keyboard.js';
|
||||
import { PointerControl } from './control/pointer.js';
|
||||
import { RangeControl } from './control/range.js';
|
||||
import { EventScopeSourceType, EventSourceState } from './state/source.js';
|
||||
import { toLowerCase } from './utils.js';
|
||||
|
||||
const bypassEventNames = [
|
||||
'beforeInput',
|
||||
|
||||
'blur',
|
||||
'focus',
|
||||
'contextMenu',
|
||||
'wheel',
|
||||
] as const;
|
||||
|
||||
const eventNames = [
|
||||
'click',
|
||||
'doubleClick',
|
||||
'tripleClick',
|
||||
|
||||
'pointerDown',
|
||||
'pointerMove',
|
||||
'pointerUp',
|
||||
'pointerOut',
|
||||
|
||||
'dragStart',
|
||||
'dragMove',
|
||||
'dragEnd',
|
||||
|
||||
'pinch',
|
||||
'pan',
|
||||
|
||||
'keyDown',
|
||||
'keyUp',
|
||||
|
||||
'selectionChange',
|
||||
'compositionStart',
|
||||
'compositionUpdate',
|
||||
'compositionEnd',
|
||||
|
||||
'cut',
|
||||
'copy',
|
||||
'paste',
|
||||
|
||||
'nativeDragStart',
|
||||
'nativeDragMove',
|
||||
'nativeDragEnd',
|
||||
'nativeDrop',
|
||||
'nativeDragOver',
|
||||
'nativeDragLeave',
|
||||
|
||||
...bypassEventNames,
|
||||
] as const;
|
||||
|
||||
export type EventName = (typeof eventNames)[number];
|
||||
export type EventOptions = {
|
||||
flavour?: string;
|
||||
blockId?: string;
|
||||
};
|
||||
export type EventHandlerRunner = {
|
||||
fn: UIEventHandler;
|
||||
flavour?: string;
|
||||
blockId?: string;
|
||||
};
|
||||
|
||||
export class UIEventDispatcher extends LifeCycleWatcher {
|
||||
private static _activeDispatcher: UIEventDispatcher | null = null;
|
||||
|
||||
static override readonly key = 'UIEventDispatcher';
|
||||
|
||||
private readonly _active = signal(false);
|
||||
|
||||
private readonly _clipboardControl: ClipboardControl;
|
||||
|
||||
private _handlersMap = Object.fromEntries(
|
||||
eventNames.map((name): [EventName, Array<EventHandlerRunner>] => [name, []])
|
||||
) as Record<EventName, Array<EventHandlerRunner>>;
|
||||
|
||||
private readonly _keyboardControl: KeyboardControl;
|
||||
|
||||
private readonly _pointerControl: PointerControl;
|
||||
|
||||
private readonly _rangeControl: RangeControl;
|
||||
|
||||
bindHotkey = (...args: Parameters<KeyboardControl['bindHotkey']>) =>
|
||||
this._keyboardControl.bindHotkey(...args);
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
private get _currentSelections() {
|
||||
return this.std.selection.value;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this._active.peek();
|
||||
}
|
||||
|
||||
get active$() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
this._pointerControl = new PointerControl(this);
|
||||
this._keyboardControl = new KeyboardControl(this);
|
||||
this._rangeControl = new RangeControl(this);
|
||||
this._clipboardControl = new ClipboardControl(this);
|
||||
this.disposables.add(this._pointerControl);
|
||||
}
|
||||
|
||||
private _bindEvents() {
|
||||
bypassEventNames.forEach(eventName => {
|
||||
this.disposables.addFromEvent(
|
||||
this.host,
|
||||
toLowerCase(eventName),
|
||||
event => {
|
||||
this.run(
|
||||
eventName,
|
||||
UIEventStateContext.from(
|
||||
new UIEventState(event),
|
||||
new EventSourceState({
|
||||
event,
|
||||
sourceType: EventScopeSourceType.Selection,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
eventName === 'wheel'
|
||||
? {
|
||||
passive: false,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
});
|
||||
|
||||
this._pointerControl.listen();
|
||||
this._keyboardControl.listen();
|
||||
this._rangeControl.listen();
|
||||
this._clipboardControl.listen();
|
||||
|
||||
let _dragging = false;
|
||||
this.disposables.addFromEvent(this.host, 'pointerdown', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerup', () => {
|
||||
_dragging = false;
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'click', () => {
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'focusin', () => {
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'focusout', e => {
|
||||
if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'blur', () => {
|
||||
if (_dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(false);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragover', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragenter', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragstart', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragend', () => {
|
||||
_dragging = false;
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'drop', () => {
|
||||
_dragging = false;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerenter', () => {
|
||||
if (this._isActiveElementOutsideHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'pointerleave', () => {
|
||||
if (
|
||||
(document.activeElement &&
|
||||
this.host.contains(document.activeElement)) ||
|
||||
_dragging
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(false);
|
||||
});
|
||||
}
|
||||
|
||||
private _buildEventScopeBySelection(name: EventName) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
const selections = this._currentSelections;
|
||||
const ids = selections.map(selection => selection.blockId);
|
||||
|
||||
return this.buildEventScope(name, ids);
|
||||
}
|
||||
|
||||
private _buildEventScopeByTarget(name: EventName, target: Node) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
// TODO(mirone/#6534): find a better way to get block element from a node
|
||||
const el = target instanceof Element ? target : target.parentElement;
|
||||
const block = el?.closest<BlockComponent>('[data-block-id]');
|
||||
|
||||
const blockId = block?.blockId;
|
||||
if (!blockId) {
|
||||
return this._buildEventScopeBySelection(name);
|
||||
}
|
||||
|
||||
return this.buildEventScope(name, [blockId]);
|
||||
}
|
||||
|
||||
private _getDeepActiveElement(): Element | null {
|
||||
let active = document.activeElement;
|
||||
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private _getEventScope(name: EventName, state: EventSourceState) {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
let output: EventHandlerRunner[] | undefined;
|
||||
|
||||
switch (state.sourceType) {
|
||||
case EventScopeSourceType.Selection: {
|
||||
output = this._buildEventScopeBySelection(name);
|
||||
break;
|
||||
}
|
||||
case EventScopeSourceType.Target: {
|
||||
output = this._buildEventScopeByTarget(
|
||||
name,
|
||||
state.event.target as Node
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
`Unknown event scope source: ${state.sourceType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private _isActiveElementOutsideHost(): boolean {
|
||||
const activeElement = this._getDeepActiveElement();
|
||||
return (
|
||||
activeElement !== null &&
|
||||
this._isEditableElementActive(activeElement) &&
|
||||
!this.host.contains(activeElement)
|
||||
);
|
||||
}
|
||||
|
||||
private _isEditableElementActive(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
return (
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLTextAreaElement ||
|
||||
(element instanceof EditorHost && !element.doc.readonly) ||
|
||||
(element as HTMLElement).isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
private _setActive(active: boolean) {
|
||||
if (active) {
|
||||
if (UIEventDispatcher._activeDispatcher !== this) {
|
||||
if (UIEventDispatcher._activeDispatcher) {
|
||||
UIEventDispatcher._activeDispatcher._active.value = false;
|
||||
}
|
||||
UIEventDispatcher._activeDispatcher = this;
|
||||
}
|
||||
this._active.value = true;
|
||||
} else {
|
||||
if (UIEventDispatcher._activeDispatcher === this) {
|
||||
UIEventDispatcher._activeDispatcher = null;
|
||||
}
|
||||
this._active.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
set active(active: boolean) {
|
||||
if (active === this._active.peek()) return;
|
||||
this._setActive(active);
|
||||
}
|
||||
|
||||
add(name: EventName, handler: UIEventHandler, options?: EventOptions) {
|
||||
const runner: EventHandlerRunner = {
|
||||
fn: handler,
|
||||
flavour: options?.flavour,
|
||||
blockId: options?.blockId,
|
||||
};
|
||||
this._handlersMap[name].unshift(runner);
|
||||
return () => {
|
||||
if (this._handlersMap[name].includes(runner)) {
|
||||
this._handlersMap[name] = this._handlersMap[name].filter(
|
||||
x => x !== runner
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildEventScope(
|
||||
name: EventName,
|
||||
blocks: string[]
|
||||
): EventHandlerRunner[] | undefined {
|
||||
const handlers = this._handlersMap[name];
|
||||
if (!handlers) return;
|
||||
|
||||
const globalEvents = handlers.filter(
|
||||
handler => handler.flavour === undefined && handler.blockId === undefined
|
||||
);
|
||||
|
||||
let blockIds: string[] = blocks;
|
||||
const events: EventHandlerRunner[] = [];
|
||||
const flavourSeen: Record<string, boolean> = {};
|
||||
while (blockIds.length > 0) {
|
||||
const idHandlers = handlers.filter(
|
||||
handler => handler.blockId && blockIds.includes(handler.blockId)
|
||||
);
|
||||
|
||||
const flavourHandlers = blockIds
|
||||
.map(blockId => this.std.store.getBlock(blockId)?.flavour)
|
||||
.filter((flavour): flavour is string => {
|
||||
if (!flavour) return false;
|
||||
if (flavourSeen[flavour]) return false;
|
||||
flavourSeen[flavour] = true;
|
||||
return true;
|
||||
})
|
||||
.flatMap(flavour => {
|
||||
return handlers.filter(handler => handler.flavour === flavour);
|
||||
});
|
||||
|
||||
events.push(...idHandlers, ...flavourHandlers);
|
||||
blockIds = blockIds
|
||||
.map(blockId => {
|
||||
const parent = this.std.store.getParent(blockId);
|
||||
return parent?.id;
|
||||
})
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
return events.concat(globalEvents);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
if (this.disposables.disposed) {
|
||||
this.disposables = new DisposableGroup();
|
||||
}
|
||||
this._bindEvents();
|
||||
|
||||
const std = this.std;
|
||||
this.std.provider
|
||||
.getAll(KeymapIdentifier)
|
||||
.forEach(({ getter, options }) => {
|
||||
this.bindHotkey(getter(std), options);
|
||||
});
|
||||
}
|
||||
|
||||
run(
|
||||
name: EventName,
|
||||
context: UIEventStateContext,
|
||||
runners?: EventHandlerRunner[]
|
||||
) {
|
||||
if (!this.active) return;
|
||||
|
||||
const sourceState = context.get('sourceState');
|
||||
if (!runners) {
|
||||
runners = this._getEventScope(name, sourceState);
|
||||
if (!runners) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const runner of runners) {
|
||||
const { fn } = runner;
|
||||
const result = fn(context);
|
||||
if (result) {
|
||||
context.get('defaultState').event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
4
blocksuite/framework/std/src/event/index.ts
Normal file
4
blocksuite/framework/std/src/event/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './base.js';
|
||||
export * from './dispatcher.js';
|
||||
export * from './keymap.js';
|
||||
export * from './state/index.js';
|
||||
127
blocksuite/framework/std/src/event/keymap.ts
Normal file
127
blocksuite/framework/std/src/event/keymap.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { base, keyName } from 'w3c-keyname';
|
||||
|
||||
import type { UIEventHandler } from './base.js';
|
||||
|
||||
function normalizeKeyName(name: string) {
|
||||
const parts = name.split(/-(?!$)/);
|
||||
let result = parts.at(-1);
|
||||
if (result === 'Space') {
|
||||
result = ' ';
|
||||
}
|
||||
let alt, ctrl, shift, meta;
|
||||
parts.slice(0, -1).forEach(mod => {
|
||||
if (/^(cmd|meta|m)$/i.test(mod)) {
|
||||
meta = true;
|
||||
return;
|
||||
}
|
||||
if (/^a(lt)?$/i.test(mod)) {
|
||||
alt = true;
|
||||
return;
|
||||
}
|
||||
if (/^(c|ctrl|control)$/i.test(mod)) {
|
||||
ctrl = true;
|
||||
return;
|
||||
}
|
||||
if (/^s(hift)?$/i.test(mod)) {
|
||||
shift = true;
|
||||
return;
|
||||
}
|
||||
if (/^mod$/i.test(mod)) {
|
||||
if (IS_MAC) {
|
||||
meta = true;
|
||||
} else {
|
||||
ctrl = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EventDispatcherError,
|
||||
'Unrecognized modifier name: ' + mod
|
||||
);
|
||||
});
|
||||
if (alt) result = 'Alt-' + result;
|
||||
if (ctrl) result = 'Ctrl-' + result;
|
||||
if (meta) result = 'Meta-' + result;
|
||||
if (shift) result = 'Shift-' + result;
|
||||
return result as string;
|
||||
}
|
||||
|
||||
function modifiers(name: string, event: KeyboardEvent, shift = true) {
|
||||
if (event.altKey) name = 'Alt-' + name;
|
||||
if (event.ctrlKey) name = 'Ctrl-' + name;
|
||||
if (event.metaKey) name = 'Meta-' + name;
|
||||
if (shift && event.shiftKey) name = 'Shift-' + name;
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalize(map: Record<string, UIEventHandler>) {
|
||||
const copy: Record<string, UIEventHandler> = Object.create(null);
|
||||
for (const prop in map) copy[normalizeKeyName(prop)] = map[prop];
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function bindKeymap(
|
||||
bindings: Record<string, UIEventHandler>
|
||||
): UIEventHandler {
|
||||
const map = normalize(bindings);
|
||||
return ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const name = keyName(event);
|
||||
const direct = map[modifiers(name, event)];
|
||||
if (direct && direct(ctx)) {
|
||||
return true;
|
||||
}
|
||||
if (name.length !== 1 || name === ' ') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const noShift = map[modifiers(name, event, false)];
|
||||
if (noShift && noShift(ctx)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// none standard keyboard, fallback to keyCode
|
||||
const special =
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.metaKey ||
|
||||
name.charCodeAt(0) > 127;
|
||||
const baseName = base[event.keyCode];
|
||||
if (special && baseName && baseName !== name) {
|
||||
const fromCode = map[modifiers(baseName, event)];
|
||||
if (fromCode && fromCode(ctx)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// In Android, the keypress event dose not contain
|
||||
// the information about what key is pressed. See
|
||||
// https://stackoverflow.com/a/68188679
|
||||
// https://stackoverflow.com/a/66724830
|
||||
export function androidBindKeymapPatch(
|
||||
bindings: Record<string, UIEventHandler>
|
||||
): UIEventHandler {
|
||||
return ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
if (
|
||||
event.inputType === 'deleteContentBackward' &&
|
||||
'Backspace' in bindings
|
||||
) {
|
||||
return bindings['Backspace'](ctx);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal file
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type ClipboardEventStateOptions = {
|
||||
event: ClipboardEvent;
|
||||
};
|
||||
|
||||
export class ClipboardEventState extends UIEventState {
|
||||
raw: ClipboardEvent;
|
||||
|
||||
override type = 'clipboardState';
|
||||
|
||||
constructor({ event }: ClipboardEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
clipboardState: ClipboardEventState;
|
||||
}
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal file
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type DndEventStateOptions = {
|
||||
event: DragEvent;
|
||||
};
|
||||
|
||||
export class DndEventState extends UIEventState {
|
||||
raw: DragEvent;
|
||||
|
||||
override type = 'dndState';
|
||||
|
||||
constructor({ event }: DndEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
dndState: DndEventState;
|
||||
}
|
||||
}
|
||||
5
blocksuite/framework/std/src/event/state/index.ts
Normal file
5
blocksuite/framework/std/src/event/state/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './clipboard.js';
|
||||
export * from './dnd.js';
|
||||
export * from './keyboard.js';
|
||||
export * from './pointer.js';
|
||||
export * from './source.js';
|
||||
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal file
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type KeyboardEventStateOptions = {
|
||||
event: KeyboardEvent;
|
||||
composing: boolean;
|
||||
};
|
||||
|
||||
export class KeyboardEventState extends UIEventState {
|
||||
composing: boolean;
|
||||
|
||||
raw: KeyboardEvent;
|
||||
|
||||
override type = 'keyboardState';
|
||||
|
||||
constructor({ event, composing }: KeyboardEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
this.raw = event;
|
||||
this.composing = composing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
keyboardState: KeyboardEventState;
|
||||
}
|
||||
}
|
||||
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal file
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
type PointerEventStateOptions = {
|
||||
event: PointerEvent;
|
||||
rect: DOMRect;
|
||||
startX: number;
|
||||
startY: number;
|
||||
last: PointerEventState | null;
|
||||
};
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
export class PointerEventState extends UIEventState {
|
||||
button: number;
|
||||
|
||||
containerOffset: Point;
|
||||
|
||||
delta: Point;
|
||||
|
||||
keys: {
|
||||
shift: boolean;
|
||||
cmd: boolean;
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
point: Point;
|
||||
|
||||
pressure: number;
|
||||
|
||||
raw: PointerEvent;
|
||||
|
||||
start: Point;
|
||||
|
||||
override type = 'pointerState';
|
||||
|
||||
get x() {
|
||||
return this.point.x;
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.point.y;
|
||||
}
|
||||
|
||||
constructor({ event, rect, startX, startY, last }: PointerEventStateOptions) {
|
||||
super(event);
|
||||
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const offsetY = event.clientY - rect.top;
|
||||
|
||||
this.raw = event;
|
||||
this.point = { x: offsetX, y: offsetY };
|
||||
this.containerOffset = { x: rect.left, y: rect.top };
|
||||
this.start = { x: startX, y: startY };
|
||||
this.delta = last
|
||||
? { x: offsetX - last.point.x, y: offsetY - last.point.y }
|
||||
: { x: 0, y: 0 };
|
||||
this.keys = {
|
||||
shift: event.shiftKey,
|
||||
cmd: event.metaKey || event.ctrlKey,
|
||||
alt: event.altKey,
|
||||
};
|
||||
this.button = last?.button || event.button;
|
||||
this.pressure = event.pressure;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiPointerEventState extends UIEventState {
|
||||
pointers: PointerEventState[];
|
||||
|
||||
override type = 'multiPointerState';
|
||||
|
||||
constructor(event: PointerEvent, pointers: PointerEventState[]) {
|
||||
super(event);
|
||||
this.pointers = pointers;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
pointerState: PointerEventState;
|
||||
multiPointerState: MultiPointerEventState;
|
||||
}
|
||||
}
|
||||
31
blocksuite/framework/std/src/event/state/source.ts
Normal file
31
blocksuite/framework/std/src/event/state/source.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { UIEventState } from '../base.js';
|
||||
|
||||
export enum EventScopeSourceType {
|
||||
// The event scope should be built by selection path
|
||||
Selection = 'selection',
|
||||
|
||||
// The event scope should be built by event target
|
||||
Target = 'target',
|
||||
}
|
||||
|
||||
export type EventSourceStateOptions = {
|
||||
event: Event;
|
||||
sourceType: EventScopeSourceType;
|
||||
};
|
||||
|
||||
export class EventSourceState extends UIEventState {
|
||||
readonly sourceType: EventScopeSourceType;
|
||||
|
||||
override type = 'sourceState';
|
||||
|
||||
constructor({ event, sourceType }: EventSourceStateOptions) {
|
||||
super(event);
|
||||
this.sourceType = sourceType;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BlockSuiteUIEventState {
|
||||
sourceState: EventSourceState;
|
||||
}
|
||||
}
|
||||
17
blocksuite/framework/std/src/event/utils.ts
Normal file
17
blocksuite/framework/std/src/event/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IPoint } from '@blocksuite/global/gfx';
|
||||
|
||||
export function isFarEnough(a: IPoint, b: IPoint) {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return Math.pow(dx, 2) + Math.pow(dy, 2) > 4;
|
||||
}
|
||||
|
||||
export function center(a: IPoint, b: IPoint) {
|
||||
return {
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export const toLowerCase = <T extends string>(str: T): Lowercase<T> =>
|
||||
str.toLowerCase() as Lowercase<T>;
|
||||
33
blocksuite/framework/std/src/extension/block-view.ts
Normal file
33
blocksuite/framework/std/src/extension/block-view.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { BlockViewIdentifier } from '../identifier.js';
|
||||
import type { BlockViewType } from '../spec/type.js';
|
||||
|
||||
/**
|
||||
* Create a block view extension.
|
||||
*
|
||||
* @param flavour The flavour of the block that the view is for.
|
||||
* @param view Lit literal template for the view. Example: `my-list-block`
|
||||
*
|
||||
* The view is a lit template that is used to render the block.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { BlockViewExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyListBlockViewExtension = BlockViewExtension(
|
||||
* 'affine:list',
|
||||
* literal`my-list-block`
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function BlockViewExtension(
|
||||
flavour: string,
|
||||
view: BlockViewType
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(BlockViewIdentifier(flavour), () => view);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
blocksuite/framework/std/src/extension/config.ts
Normal file
44
blocksuite/framework/std/src/extension/config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { ConfigIdentifier } from '../identifier.js';
|
||||
|
||||
export interface ConfigFactory<Config extends Record<string, any>> {
|
||||
(config: Config): ExtensionType;
|
||||
identifier: ServiceIdentifier<Config>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config extension.
|
||||
* A config extension provides a configuration object for a block flavour.
|
||||
* The configuration object can be used like:
|
||||
* ```ts
|
||||
* const config = std.provider.getOptional(ConfigIdentifier('my-flavour'));
|
||||
* ```
|
||||
*
|
||||
* @param configId The id of the config. Should be unique for each config.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
* const MyConfigExtensionFactory = ConfigExtensionFactory<ConfigType>('my-flavour');
|
||||
* const MyConfigExtension = MyConfigExtensionFactory({
|
||||
* option1: 'value1',
|
||||
* option2: 'value2',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function ConfigExtensionFactory<Config extends Record<string, any>>(
|
||||
configId: string
|
||||
): ConfigFactory<Config> {
|
||||
const identifier = ConfigIdentifier(configId) as ServiceIdentifier<Config>;
|
||||
const extensionFactory = (config: Config): ExtensionType => ({
|
||||
setup: di => {
|
||||
di.override(ConfigIdentifier(configId), () => {
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
extensionFactory.identifier = identifier;
|
||||
return extensionFactory;
|
||||
}
|
||||
373
blocksuite/framework/std/src/extension/dnd/index.ts
Normal file
373
blocksuite/framework/std/src/extension/dnd/index.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
type ElementGetFeedbackArgs,
|
||||
monitorForElements,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
|
||||
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
||||
import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
|
||||
import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
||||
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
import {
|
||||
attachClosestEdge,
|
||||
type Edge,
|
||||
extractClosestEdge,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import { LifeCycleWatcherIdentifier } from '../../identifier.js';
|
||||
import { LifeCycleWatcher } from '../lifecycle-watcher.js';
|
||||
import type {
|
||||
ElementDragEventBaseArgs,
|
||||
ElementDragEventMap,
|
||||
ElementDropEventMap,
|
||||
ElementDropTargetFeedbackArgs,
|
||||
ElementMonitorFeedbackArgs,
|
||||
OriginalAutoScrollOption,
|
||||
OriginalDraggableOption,
|
||||
OriginalDropTargetOption,
|
||||
OriginalMonitorOption,
|
||||
} from './types.js';
|
||||
|
||||
export type DragEntity = { type: string };
|
||||
|
||||
export type DragFrom = { at: string };
|
||||
|
||||
export type DragFromBlockSuite = {
|
||||
at: 'blocksuite-editor';
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export type DragPayload<
|
||||
E extends DragEntity = DragEntity,
|
||||
F extends DragFrom = DragFromBlockSuite,
|
||||
> = {
|
||||
bsEntity?: E;
|
||||
from?: F;
|
||||
};
|
||||
|
||||
export type DropPayload<T extends {} = {}> = {
|
||||
edge?: Edge;
|
||||
} & T;
|
||||
|
||||
export type DropEdge = Edge;
|
||||
|
||||
export interface DNDEntity {
|
||||
basic: DragEntity;
|
||||
}
|
||||
|
||||
export type DraggableOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = Pick<OriginalDraggableOption, 'element' | 'dragHandle' | 'canDrag'> & {
|
||||
/**
|
||||
* Set drag data for the draggable element.
|
||||
* @see {@link ElementGetFeedbackArgs} for callback arguments
|
||||
* @param callback - callback to set drag
|
||||
*/
|
||||
setDragData?: (args: ElementGetFeedbackArgs) => PayloadEntity;
|
||||
|
||||
/**
|
||||
* Set external drag data for the draggable element.
|
||||
* @param callback - callback to set external drag data
|
||||
* @see {@link ElementGetFeedbackArgs} for callback arguments
|
||||
*/
|
||||
setExternalDragData?: (
|
||||
args: ElementGetFeedbackArgs
|
||||
) => ReturnType<
|
||||
Required<OriginalDraggableOption>['getInitialDataForExternal']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Set custom drag preview for the draggable element.
|
||||
*
|
||||
* `setDragPreview` is a function that will be called with a `container` element and other drag data as parameter when the drag preview is generating.
|
||||
* Append the custom element to the `container` which will be used to generate the preview. Once the drag preview is generated, the
|
||||
* `container` element and its children will be removed automatically.
|
||||
*
|
||||
* If you want to completely disable the drag preview, just set `setDragPreview` to `false`.
|
||||
*
|
||||
* @example
|
||||
* dnd.draggable({
|
||||
* // ...
|
||||
* setDragPreview: ({ container }) => {
|
||||
* const preview = document.createElement('div');
|
||||
* preview.style.width = '100px';
|
||||
* preview.style.height = '100px';
|
||||
* preview.style.backgroundColor = 'red';
|
||||
* preview.innerText = 'Custom Drag Preview';
|
||||
* container.appendChild(preview);
|
||||
*
|
||||
* return () => {
|
||||
* // do some cleanup
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* @param callback - callback to set custom drag preview
|
||||
* @returns
|
||||
*/
|
||||
setDragPreview?:
|
||||
| false
|
||||
| ((
|
||||
options: ElementDragEventBaseArgs<
|
||||
DragPayload<PayloadEntity, PayloadFrom>
|
||||
> & {
|
||||
/**
|
||||
* Allows you to use the native `setDragImage` function if you want
|
||||
* Although, we recommend using alternative techniques (see element adapter docs)
|
||||
*/
|
||||
nativeSetDragImage: DataTransfer['setDragImage'] | null;
|
||||
container: HTMLElement;
|
||||
setOffset: (
|
||||
offset: 'preserve' | 'center' | { x: number; y: number }
|
||||
) => void;
|
||||
}
|
||||
) => void | (() => void));
|
||||
} & ElementDragEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type DropTargetOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = {
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.element}
|
||||
*/
|
||||
element: HTMLElement;
|
||||
|
||||
/**
|
||||
* Allow drop position for the drop target.
|
||||
*/
|
||||
allowDropPosition?: Edge[];
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getDropEffect}
|
||||
*/
|
||||
getDropEffect?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => DropTargetRecord['dropEffect'];
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.canDrop}
|
||||
*/
|
||||
canDrop?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getData}
|
||||
*/
|
||||
setDropData?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => DropPayload;
|
||||
|
||||
/**
|
||||
* {@link OriginalDropTargetOption.getIsSticky}
|
||||
*/
|
||||
getIsSticky?: (
|
||||
args: ElementDropTargetFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
} & ElementDropEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type MonitorOption<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
DropPayload extends {},
|
||||
> = {
|
||||
/**
|
||||
* {@link OriginalMonitorOption.canMonitor}
|
||||
*/
|
||||
canMonitor?: (
|
||||
args: ElementMonitorFeedbackArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => boolean;
|
||||
} & ElementDragEventMap<DragPayload<PayloadEntity, PayloadFrom>, DropPayload>;
|
||||
|
||||
export type AutoScroll<
|
||||
PayloadEntity extends DragEntity,
|
||||
PayloadFrom extends DragFrom,
|
||||
> = {
|
||||
element: HTMLElement;
|
||||
canScroll?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => void;
|
||||
getAllowedAxis?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => ReturnType<Required<OriginalAutoScrollOption>['getAllowedAxis']>;
|
||||
getConfiguration?: (
|
||||
args: ElementDragEventBaseArgs<DragPayload<PayloadEntity, PayloadFrom>>
|
||||
) => ReturnType<Required<OriginalAutoScrollOption>['getConfiguration']>;
|
||||
};
|
||||
|
||||
export const DndExtensionIdentifier = LifeCycleWatcherIdentifier(
|
||||
'DndController'
|
||||
) as ServiceIdentifier<DndController>;
|
||||
|
||||
export class DndController extends LifeCycleWatcher {
|
||||
static override key = 'DndController';
|
||||
|
||||
/**
|
||||
* Make an element draggable.
|
||||
*/
|
||||
draggable<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
>(
|
||||
args: DraggableOption<
|
||||
PayloadEntity,
|
||||
DragFromBlockSuite,
|
||||
DropPayload<DropData>
|
||||
>
|
||||
) {
|
||||
const {
|
||||
setDragData,
|
||||
setExternalDragData,
|
||||
setDragPreview,
|
||||
element,
|
||||
dragHandle,
|
||||
...rest
|
||||
} = args;
|
||||
|
||||
return draggable({
|
||||
...(rest as Partial<OriginalDraggableOption>),
|
||||
element,
|
||||
dragHandle,
|
||||
onGenerateDragPreview: options => {
|
||||
if (setDragPreview) {
|
||||
let state: typeof centerUnderPointer | { x: number; y: number };
|
||||
|
||||
const setOffset = (
|
||||
offset: 'preserve' | 'center' | { x: number; y: number }
|
||||
) => {
|
||||
if (offset === 'center') {
|
||||
state = centerUnderPointer;
|
||||
} else if (offset === 'preserve') {
|
||||
state = preserveOffsetOnSource({
|
||||
element: options.source.element,
|
||||
input: options.location.current.input,
|
||||
});
|
||||
} else if (typeof offset === 'object') {
|
||||
if (
|
||||
offset.x < 0 ||
|
||||
offset.y < 0 ||
|
||||
typeof offset.x === 'string' ||
|
||||
typeof offset.y === 'string'
|
||||
) {
|
||||
state = pointerOutsideOfPreview({
|
||||
x:
|
||||
typeof offset.x === 'number'
|
||||
? `${Math.abs(offset.x)}px`
|
||||
: offset.x,
|
||||
y:
|
||||
typeof offset.y === 'number'
|
||||
? `${Math.abs(offset.y)}px`
|
||||
: offset.y,
|
||||
});
|
||||
}
|
||||
state = offset;
|
||||
}
|
||||
};
|
||||
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: (...args) => {
|
||||
if (!state) {
|
||||
setOffset('center');
|
||||
}
|
||||
|
||||
if (typeof state === 'function') {
|
||||
return state(...args);
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
render: renderOption => {
|
||||
setDragPreview({
|
||||
setOffset,
|
||||
...options,
|
||||
...renderOption,
|
||||
});
|
||||
},
|
||||
nativeSetDragImage: options.nativeSetDragImage,
|
||||
});
|
||||
} else if (setDragPreview === false) {
|
||||
disableNativeDragPreview({
|
||||
nativeSetDragImage: options.nativeSetDragImage,
|
||||
});
|
||||
}
|
||||
},
|
||||
getInitialData: options => {
|
||||
const bsEntity = setDragData?.(options) ?? {};
|
||||
|
||||
return {
|
||||
bsEntity,
|
||||
from: {
|
||||
at: 'blocksuite-editor',
|
||||
docId: this.std.store.doc.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
getInitialDataForExternal: setExternalDragData
|
||||
? options => {
|
||||
return setExternalDragData?.(options);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an element a drop target.
|
||||
*/
|
||||
dropTarget<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(args: DropTargetOption<PayloadEntity, PayloadFrom, DropPayload<DropData>>) {
|
||||
const {
|
||||
element,
|
||||
setDropData,
|
||||
allowDropPosition = ['bottom', 'left', 'top', 'right'],
|
||||
...rest
|
||||
} = args;
|
||||
|
||||
return dropTargetForElements({
|
||||
element,
|
||||
getData: options => {
|
||||
const data = setDropData?.(options) ?? {};
|
||||
const edge = extractClosestEdge(
|
||||
attachClosestEdge(data, {
|
||||
element: options.element,
|
||||
input: options.input,
|
||||
allowedEdges: allowDropPosition,
|
||||
})
|
||||
);
|
||||
|
||||
return edge
|
||||
? {
|
||||
...data,
|
||||
edge,
|
||||
}
|
||||
: data;
|
||||
},
|
||||
...(rest as Partial<OriginalDropTargetOption>),
|
||||
});
|
||||
}
|
||||
|
||||
monitor<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
DropData extends {} = {},
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(args: MonitorOption<PayloadEntity, PayloadFrom, DropPayload<DropData>>) {
|
||||
return monitorForElements(args as OriginalMonitorOption);
|
||||
}
|
||||
|
||||
autoScroll<
|
||||
PayloadEntity extends DragEntity = DragEntity,
|
||||
PayloadFrom extends DragFrom = DragFromBlockSuite,
|
||||
>(options: AutoScroll<PayloadEntity, PayloadFrom>) {
|
||||
return autoScrollForElements(options as OriginalAutoScrollOption);
|
||||
}
|
||||
}
|
||||
118
blocksuite/framework/std/src/extension/dnd/types.ts
Normal file
118
blocksuite/framework/std/src/extension/dnd/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
ElementDropTargetGetFeedbackArgs,
|
||||
ElementMonitorGetFeedbackArgs,
|
||||
monitorForElements,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import type {
|
||||
DragLocation,
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragType,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import type { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
|
||||
export type ElementDragEventBaseArgs<Payload, DropPayload = {}> = {
|
||||
/**
|
||||
* {@link DragLocationHistory} of the drag
|
||||
*/
|
||||
location: {
|
||||
/**
|
||||
* {@link DragLocationHistory.initial}
|
||||
*/
|
||||
initial: DragLocationWithPayload<DropPayload>;
|
||||
/**
|
||||
* {@link DragLocationHistory.current}
|
||||
*/
|
||||
current: DragLocationWithPayload<DropPayload>;
|
||||
/**
|
||||
* {@link DragLocationHistory.previous}
|
||||
*/
|
||||
previous: Pick<DragLocationWithPayload<DropPayload>, 'dropTargets'>;
|
||||
};
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type DragLocationWithPayload<Payload> = Omit<
|
||||
DragLocation,
|
||||
'dropTargets'
|
||||
> & {
|
||||
dropTargets: DropTargetRecordWithPayload<Payload>[];
|
||||
};
|
||||
|
||||
export type DropTargetRecordWithPayload<Payload> = Omit<
|
||||
DropTargetRecord,
|
||||
'data'
|
||||
> & {
|
||||
data: Payload;
|
||||
};
|
||||
|
||||
export type ElementDragEventMap<DragPayload, DropPayload> = {
|
||||
onDragStart?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload>
|
||||
) => void;
|
||||
onDrag?: (data: ElementDragEventBaseArgs<DragPayload, DropPayload>) => void;
|
||||
onDrop?: (data: ElementDragEventBaseArgs<DragPayload, DropPayload>) => void;
|
||||
onDropTargetChange?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload>
|
||||
) => void;
|
||||
};
|
||||
|
||||
type DropTargetLocalizedData = {
|
||||
self: DropTargetRecord;
|
||||
};
|
||||
|
||||
export type ElementDropTargetFeedbackArgs<Payload> = Omit<
|
||||
ElementDropTargetGetFeedbackArgs,
|
||||
'source'
|
||||
> & {
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type ElementDropEventMap<DragPayload, DropPayload> = {
|
||||
onDragStart?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDrag?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDrop?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDropTargetChange?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDragEnter?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
onDragLeave?: (
|
||||
data: ElementDragEventBaseArgs<DragPayload, DropPayload> &
|
||||
DropTargetLocalizedData
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type ElementMonitorFeedbackArgs<Payload> = Omit<
|
||||
ElementMonitorGetFeedbackArgs,
|
||||
'source'
|
||||
> & {
|
||||
source: Omit<ElementDragType['payload'], 'data'> & { data: Payload };
|
||||
};
|
||||
|
||||
export type OriginalDraggableOption = Parameters<typeof draggable>[0];
|
||||
|
||||
export type OriginalDropTargetOption = Parameters<
|
||||
typeof dropTargetForElements
|
||||
>[0];
|
||||
|
||||
export type OriginalMonitorOption = Parameters<typeof monitorForElements>[0];
|
||||
|
||||
export type OriginalAutoScrollOption = Parameters<
|
||||
typeof autoScrollForElements
|
||||
>[0];
|
||||
49
blocksuite/framework/std/src/extension/editor-life-cycle.ts
Normal file
49
blocksuite/framework/std/src/extension/editor-life-cycle.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { BlockStdScope } from '../scope/std-scope';
|
||||
import { LifeCycleWatcher } from './lifecycle-watcher';
|
||||
|
||||
export class EditorLifeCycleExtension extends LifeCycleWatcher {
|
||||
static override key = 'editor-life-cycle';
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
readonly slots = {
|
||||
created: new Subject<void>(),
|
||||
mounted: new Subject<void>(),
|
||||
rendered: new Subject<void>(),
|
||||
unmounted: new Subject<void>(),
|
||||
};
|
||||
|
||||
constructor(override readonly std: BlockStdScope) {
|
||||
super(std);
|
||||
|
||||
this.disposables.add(this.slots.created);
|
||||
this.disposables.add(this.slots.mounted);
|
||||
this.disposables.add(this.slots.rendered);
|
||||
this.disposables.add(this.slots.unmounted);
|
||||
}
|
||||
|
||||
override created() {
|
||||
super.created();
|
||||
this.slots.created.next();
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
super.mounted();
|
||||
this.slots.mounted.next();
|
||||
}
|
||||
|
||||
override rendered() {
|
||||
super.rendered();
|
||||
this.slots.rendered.next();
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
super.unmounted();
|
||||
this.slots.unmounted.next();
|
||||
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
26
blocksuite/framework/std/src/extension/flavour.ts
Normal file
26
blocksuite/framework/std/src/extension/flavour.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { BlockFlavourIdentifier } from '../identifier.js';
|
||||
|
||||
/**
|
||||
* Create a flavour extension.
|
||||
*
|
||||
* @param flavour
|
||||
* The flavour of the block that the extension is for.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { FlavourExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyFlavourExtension = FlavourExtension('my-flavour');
|
||||
* ```
|
||||
*/
|
||||
export function FlavourExtension(flavour: string): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(BlockFlavourIdentifier(flavour), () => ({
|
||||
flavour,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
10
blocksuite/framework/std/src/extension/index.ts
Normal file
10
blocksuite/framework/std/src/extension/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './block-view.js';
|
||||
export * from './config.js';
|
||||
export * from './dnd/index.js';
|
||||
export * from './editor-life-cycle.js';
|
||||
export * from './flavour.js';
|
||||
export * from './keymap.js';
|
||||
export * from './lifecycle-watcher.js';
|
||||
export * from './service.js';
|
||||
export * from './service-manager.js';
|
||||
export * from './widget-view-map.js';
|
||||
49
blocksuite/framework/std/src/extension/keymap.ts
Normal file
49
blocksuite/framework/std/src/extension/keymap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { EventOptions, UIEventHandler } from '../event/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
let id = 1;
|
||||
|
||||
/**
|
||||
* Create a keymap extension.
|
||||
*
|
||||
* @param keymapFactory
|
||||
* Create keymap of the extension.
|
||||
* It should return an object with `keymap` and `options`.
|
||||
*
|
||||
* `keymap` is a record of keymap.
|
||||
*
|
||||
* @param options
|
||||
* `options` is an optional object that restricts the event to be handled.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { KeymapExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyKeymapExtension = KeymapExtension(std => {
|
||||
* return {
|
||||
* keymap: {
|
||||
* 'mod-a': SelectAll
|
||||
* }
|
||||
* options: {
|
||||
* flavour: 'affine:paragraph'
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function KeymapExtension(
|
||||
keymapFactory: (std: BlockStdScope) => Record<string, UIEventHandler>,
|
||||
options?: EventOptions
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(KeymapIdentifier(`Keymap-${id++}`), {
|
||||
getter: keymapFactory,
|
||||
options,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
70
blocksuite/framework/std/src/extension/lifecycle-watcher.ts
Normal file
70
blocksuite/framework/std/src/extension/lifecycle-watcher.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { LifeCycleWatcherIdentifier, StdIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
/**
|
||||
* A life cycle watcher is an extension that watches the life cycle of the editor.
|
||||
* It is used to perform actions when the editor is created, mounted, rendered, or unmounted.
|
||||
*
|
||||
* When creating a life cycle watcher, you must define a key that is unique to the watcher.
|
||||
* The key is used to identify the watcher in the dependency injection container.
|
||||
* ```ts
|
||||
* class MyLifeCycleWatcher extends LifeCycleWatcher {
|
||||
* static override readonly key = 'my-life-cycle-watcher';
|
||||
* ```
|
||||
*
|
||||
* In the life cycle watcher, the methods will be called in the following order:
|
||||
* 1. `created`: Called when the std is created.
|
||||
* 2. `rendered`: Called when `std.render` is called.
|
||||
* 3. `mounted`: Called when the editor host is mounted.
|
||||
* 4. `unmounted`: Called when the editor host is unmounted.
|
||||
*/
|
||||
export abstract class LifeCycleWatcher extends Extension {
|
||||
static key: string;
|
||||
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Key is not defined in the LifeCycleWatcher'
|
||||
);
|
||||
}
|
||||
|
||||
di.add(this as unknown as { new (std: BlockStdScope): LifeCycleWatcher }, [
|
||||
StdIdentifier,
|
||||
]);
|
||||
|
||||
di.addImpl(LifeCycleWatcherIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when std is created.
|
||||
*/
|
||||
created() {}
|
||||
|
||||
/**
|
||||
* Called when editor host is mounted.
|
||||
* Which means the editor host emit the `connectedCallback` lifecycle event.
|
||||
*/
|
||||
mounted() {}
|
||||
|
||||
/**
|
||||
* Called when `std.render` is called.
|
||||
*/
|
||||
rendered() {}
|
||||
|
||||
/**
|
||||
* Called when editor host is unmounted.
|
||||
* Which means the editor host emit the `disconnectedCallback` lifecycle event.
|
||||
*/
|
||||
unmounted() {}
|
||||
}
|
||||
22
blocksuite/framework/std/src/extension/service-manager.ts
Normal file
22
blocksuite/framework/std/src/extension/service-manager.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { BlockServiceIdentifier } from '../identifier.js';
|
||||
|
||||
export class ServiceManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'serviceManager';
|
||||
|
||||
override mounted() {
|
||||
super.mounted();
|
||||
|
||||
this.std.provider.getAll(BlockServiceIdentifier).forEach(service => {
|
||||
service.mounted();
|
||||
});
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
super.unmounted();
|
||||
|
||||
this.std.provider.getAll(BlockServiceIdentifier).forEach(service => {
|
||||
service.unmounted();
|
||||
});
|
||||
}
|
||||
}
|
||||
113
blocksuite/framework/std/src/extension/service.ts
Normal file
113
blocksuite/framework/std/src/extension/service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { EventName, UIEventHandler } from '../event/index.js';
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockServiceIdentifier,
|
||||
StdIdentifier,
|
||||
} from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* BlockService is deprecated. You should reconsider where to put your feature.
|
||||
*
|
||||
* BlockService is a legacy extension that is used to provide services to the block.
|
||||
* In the previous version of BlockSuite, block service provides a way to extend the block.
|
||||
* However, in the new version, we recommend using the new extension system.
|
||||
*/
|
||||
export abstract class BlockService extends Extension {
|
||||
static flavour: string;
|
||||
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly flavour: string;
|
||||
|
||||
get collection() {
|
||||
return this.std.workspace;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get selectionManager() {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
get uiEventDispatcher() {
|
||||
return this.std.event;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly std: BlockStdScope,
|
||||
readonly flavourProvider: { flavour: string }
|
||||
) {
|
||||
super();
|
||||
this.flavour = flavourProvider.flavour;
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.flavour) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Flavour is not defined in the BlockService'
|
||||
);
|
||||
}
|
||||
di.add(
|
||||
this as unknown as {
|
||||
new (
|
||||
std: BlockStdScope,
|
||||
flavourProvider: { flavour: string }
|
||||
): BlockService;
|
||||
},
|
||||
[StdIdentifier, BlockFlavourIdentifier(this.flavour)]
|
||||
);
|
||||
di.addImpl(BlockServiceIdentifier(this.flavour), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
bindHotKey(
|
||||
keymap: Record<string, UIEventHandler>,
|
||||
options?: { global: boolean }
|
||||
) {
|
||||
this.disposables.add(
|
||||
this.uiEventDispatcher.bindHotkey(keymap, {
|
||||
flavour: options?.global ? undefined : this.flavour,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// life cycle start
|
||||
dispose() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
// event handlers start
|
||||
handleEvent(
|
||||
name: EventName,
|
||||
fn: UIEventHandler,
|
||||
options?: { global: boolean }
|
||||
) {
|
||||
this.disposables.add(
|
||||
this.uiEventDispatcher.add(name, fn, {
|
||||
flavour: options?.global ? undefined : this.flavour,
|
||||
})
|
||||
);
|
||||
}
|
||||
// life cycle end
|
||||
|
||||
mounted() {}
|
||||
|
||||
unmounted() {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
40
blocksuite/framework/std/src/extension/widget-view-map.ts
Normal file
40
blocksuite/framework/std/src/extension/widget-view-map.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { WidgetViewIdentifier } from '../identifier.js';
|
||||
import type { WidgetViewType } from '../spec/type.js';
|
||||
|
||||
/**
|
||||
* Create a widget view extension.
|
||||
*
|
||||
* @param flavour The flavour of the block that the widget view is for.
|
||||
* @param id The id of the widget view.
|
||||
* @param view The widget view lit literal.
|
||||
*
|
||||
* A widget view is to provide a widget view for a block.
|
||||
* For every target block, it's view will be rendered with the widget view.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { WidgetViewExtension } from '@blocksuite/std';
|
||||
*
|
||||
* const MyWidgetViewExtension = WidgetViewExtension('my-flavour', 'my-widget', literal`my-widget-view`);
|
||||
*/
|
||||
export function WidgetViewExtension(
|
||||
flavour: string,
|
||||
id: string,
|
||||
view: WidgetViewType
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
if (flavour.includes('|') || id.includes('|')) {
|
||||
console.error(`Register view failed:`);
|
||||
console.error(
|
||||
`flavour or id cannot include '|', flavour: ${flavour}, id: ${id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${flavour}|${id}`;
|
||||
di.addImpl(WidgetViewIdentifier(key), view);
|
||||
},
|
||||
};
|
||||
}
|
||||
336
blocksuite/framework/std/src/gfx/controller.ts
Normal file
336
blocksuite/framework/std/src/gfx/controller.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
Bound,
|
||||
getCommonBound,
|
||||
getCommonBoundWithRotation,
|
||||
type IBound,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import { onSurfaceAdded } from '../utils/gfx.js';
|
||||
import type { BlockComponent } from '../view/index.js';
|
||||
import type { CursorType } from './cursor.js';
|
||||
import {
|
||||
GfxClassExtenderIdentifier,
|
||||
GfxExtensionIdentifier,
|
||||
} from './extension.js';
|
||||
import { GridManager } from './grid.js';
|
||||
import { gfxControllerKey } from './identifiers.js';
|
||||
import { KeyboardController } from './keyboard.js';
|
||||
import { LayerManager } from './layer.js';
|
||||
import type { PointTestOptions } from './model/base.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import {
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from './model/surface/element-model.js';
|
||||
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.js';
|
||||
|
||||
export class GfxController extends LifeCycleWatcher {
|
||||
static override key = gfxControllerKey;
|
||||
|
||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
||||
|
||||
private readonly _surface$ = new Signal<SurfaceBlockModel | null>(null);
|
||||
|
||||
readonly cursor$ = new Signal<CursorType>();
|
||||
|
||||
readonly keyboard: KeyboardController;
|
||||
|
||||
readonly viewport: Viewport = new Viewport();
|
||||
|
||||
get grid() {
|
||||
return this.std.get(GridManager);
|
||||
}
|
||||
|
||||
get layer() {
|
||||
return this.std.get(LayerManager);
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get elementsBound() {
|
||||
return getCommonBoundWithRotation(this.gfxElements);
|
||||
}
|
||||
|
||||
get gfxElements(): GfxModel[] {
|
||||
return [...this.layer.blocks, ...this.layer.canvasElements];
|
||||
}
|
||||
|
||||
get surface$() {
|
||||
return this._surface$;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface$.peek();
|
||||
}
|
||||
|
||||
get surfaceComponent(): BlockComponent | null {
|
||||
return this.surface
|
||||
? (this.std.view.getBlock(this.surface.id) ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
|
||||
this.keyboard = new KeyboardController(std);
|
||||
|
||||
this._disposables.add(
|
||||
onSurfaceAdded(this.doc, surface => {
|
||||
this._surface$.value = surface;
|
||||
})
|
||||
);
|
||||
this._disposables.add(this.viewport);
|
||||
this._disposables.add(this.keyboard);
|
||||
|
||||
this.std.provider.getAll(GfxClassExtenderIdentifier).forEach(ext => {
|
||||
ext.extendFn(this);
|
||||
});
|
||||
}
|
||||
|
||||
deleteElement(element: GfxModel | BlockModel<object> | string): void {
|
||||
element = typeof element === 'string' ? element : element.id;
|
||||
|
||||
assertType<string>(element);
|
||||
|
||||
if (this.surface?.hasElementById(element)) {
|
||||
this.surface.deleteElement(element);
|
||||
} else {
|
||||
const block = this.doc.getBlock(element)?.model;
|
||||
block && this.doc.deleteBlock(block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a block or element by its id.
|
||||
* Note that non-gfx block can also be queried in this method.
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
getElementById<
|
||||
T extends GfxModel | BlockModel<object> = GfxModel | BlockModel<object>,
|
||||
>(id: string): T | null {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return (
|
||||
this.surface?.getElementById(id) ?? this.doc.getBlock(id)?.model ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get elements on a specific point.
|
||||
* @param x
|
||||
* @param y
|
||||
* @param options
|
||||
*/
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: { all: true } & PointTestOptions
|
||||
): GfxModel[];
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: { all?: false } & PointTestOptions
|
||||
): GfxModel | null;
|
||||
getElementByPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions & {
|
||||
all?: boolean;
|
||||
} = { all: false, hitThreshold: 10 }
|
||||
): GfxModel | GfxModel[] | null {
|
||||
options.zoom = this.viewport.zoom;
|
||||
options.hitThreshold ??= 10;
|
||||
|
||||
const hitThreshold = options.hitThreshold;
|
||||
const responsePadding = options.responsePadding ?? [
|
||||
hitThreshold / 2,
|
||||
hitThreshold / 2,
|
||||
];
|
||||
const all = options.all ?? false;
|
||||
const hitTestBound = {
|
||||
x: x - responsePadding[0],
|
||||
y: y - responsePadding[1],
|
||||
w: responsePadding[0] * 2,
|
||||
h: responsePadding[1] * 2,
|
||||
};
|
||||
|
||||
const candidates = this.grid.search(hitTestBound);
|
||||
const picked = candidates.filter(
|
||||
elm =>
|
||||
elm.includesPoint(x, y, options as PointTestOptions, this.std.host) ||
|
||||
elm.externalBound?.isPointInBound([x, y])
|
||||
);
|
||||
|
||||
picked.sort(this.layer.compare);
|
||||
|
||||
if (all) {
|
||||
return picked;
|
||||
}
|
||||
|
||||
return last(picked) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top element in the given point.
|
||||
* If the element is in a group, the group will be returned.
|
||||
* If the group is currently selected, the child element will be returned.
|
||||
* @param x
|
||||
* @param y
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
getElementInGroup(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: PointTestOptions
|
||||
): GfxModel | null {
|
||||
const selectionManager = this.selection;
|
||||
const results = this.getElementByPoint(x, y, {
|
||||
...options,
|
||||
all: true,
|
||||
});
|
||||
|
||||
let picked = last(results) ?? null;
|
||||
const { activeGroup } = selectionManager;
|
||||
const first = picked;
|
||||
|
||||
if (activeGroup && picked && activeGroup.hasDescendant(picked)) {
|
||||
let index = results.length - 1;
|
||||
|
||||
while (
|
||||
picked === activeGroup ||
|
||||
(picked instanceof GfxGroupLikeElementModel &&
|
||||
picked.hasDescendant(activeGroup))
|
||||
) {
|
||||
picked = results[--index];
|
||||
}
|
||||
} else if (picked) {
|
||||
let index = results.length - 1;
|
||||
|
||||
while (picked.group instanceof GfxGroupLikeElementModel) {
|
||||
if (--index < 0) {
|
||||
picked = null;
|
||||
break;
|
||||
}
|
||||
picked = results[index];
|
||||
}
|
||||
}
|
||||
|
||||
return (picked ?? first) as GfxModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all elements in an area.
|
||||
* @param bound
|
||||
* @param options
|
||||
*/
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options?: { type: 'all' }
|
||||
): GfxModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'canvas' }
|
||||
): GfxPrimitiveElementModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'block' }
|
||||
): GfxBlockElementModel[];
|
||||
|
||||
getElementsByBound(
|
||||
bound: IBound | Bound,
|
||||
options: { type: 'block' | 'canvas' | 'all' } = {
|
||||
type: 'all',
|
||||
}
|
||||
): GfxModel[] {
|
||||
bound = bound instanceof Bound ? bound : Bound.from(bound);
|
||||
|
||||
let candidates = this.grid.search(bound);
|
||||
|
||||
if (options.type !== 'all') {
|
||||
const filter =
|
||||
options.type === 'block'
|
||||
? (elm: GfxModel) => elm instanceof GfxBlockElementModel
|
||||
: (elm: GfxModel) => elm instanceof GfxPrimitiveElementModel;
|
||||
|
||||
candidates = candidates.filter(filter);
|
||||
}
|
||||
|
||||
candidates.sort(this.layer.compare);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
getElementsByType(type: string): (GfxModel | BlockModel<object>)[] {
|
||||
return (
|
||||
this.surface?.getElementsByType(type) ??
|
||||
this.doc.getBlocksByFlavour(type).map(b => b.model)
|
||||
);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.viewport.setShellElement(this.std.host);
|
||||
this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => {
|
||||
ext.mounted();
|
||||
});
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => {
|
||||
ext.unmounted();
|
||||
});
|
||||
this.viewport.clearViewportElement();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
updateElement(
|
||||
element: GfxModel | string,
|
||||
props: Record<string, unknown>
|
||||
): void {
|
||||
const elemId = typeof element === 'string' ? element : element.id;
|
||||
|
||||
if (this.surface?.hasElementById(elemId)) {
|
||||
this.surface.updateElement(elemId, props);
|
||||
} else {
|
||||
const block = this.doc.getBlock(elemId);
|
||||
block && this.doc.updateBlock(block.model, props);
|
||||
}
|
||||
}
|
||||
|
||||
fitToScreen(
|
||||
options: {
|
||||
bounds?: Bound[];
|
||||
smooth?: boolean;
|
||||
padding?: [number, number, number, number];
|
||||
} = {
|
||||
smooth: false,
|
||||
padding: [0, 0, 0, 0],
|
||||
}
|
||||
) {
|
||||
const elemBounds =
|
||||
options.bounds ??
|
||||
this.gfxElements.map(element => Bound.deserialize(element.xywh));
|
||||
const commonBound = getCommonBound(elemBounds);
|
||||
const { zoom, centerX, centerY } = this.viewport.getFitToScreenData(
|
||||
commonBound,
|
||||
options.padding,
|
||||
ZOOM_INITIAL,
|
||||
FIT_TO_SCREEN_PADDING
|
||||
);
|
||||
|
||||
this.viewport.setViewport(zoom, [centerX, centerY], options.smooth);
|
||||
}
|
||||
}
|
||||
54
blocksuite/framework/std/src/gfx/cursor.ts
Normal file
54
blocksuite/framework/std/src/gfx/cursor.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type StandardCursor =
|
||||
| 'default'
|
||||
| 'pointer'
|
||||
| 'move'
|
||||
| 'text'
|
||||
| 'crosshair'
|
||||
| 'not-allowed'
|
||||
| 'grab'
|
||||
| 'grabbing'
|
||||
| 'nwse-resize'
|
||||
| 'nesw-resize'
|
||||
| 'ew-resize'
|
||||
| 'ns-resize'
|
||||
| 'n-resize'
|
||||
| 's-resize'
|
||||
| 'w-resize'
|
||||
| 'e-resize'
|
||||
| 'ne-resize'
|
||||
| 'se-resize'
|
||||
| 'sw-resize'
|
||||
| 'nw-resize'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'help'
|
||||
| 'wait'
|
||||
| 'progress'
|
||||
| 'copy'
|
||||
| 'alias'
|
||||
| 'context-menu'
|
||||
| 'cell'
|
||||
| 'vertical-text'
|
||||
| 'no-drop'
|
||||
| 'not-allowed'
|
||||
| 'all-scroll'
|
||||
| 'col-resize'
|
||||
| 'row-resize'
|
||||
| 'none'
|
||||
| 'inherit'
|
||||
| 'initial'
|
||||
| 'unset';
|
||||
|
||||
export type URLCursor = `url(${string})`;
|
||||
|
||||
export type URLCursorWithCoords = `url(${string}) ${number} ${number}`;
|
||||
|
||||
export type URLCursorWithFallback =
|
||||
| `${URLCursor}, ${StandardCursor}`
|
||||
| `${URLCursorWithCoords}, ${StandardCursor}`;
|
||||
|
||||
export type CursorType =
|
||||
| StandardCursor
|
||||
| URLCursor
|
||||
| URLCursorWithCoords
|
||||
| URLCursorWithFallback;
|
||||
73
blocksuite/framework/std/src/gfx/element-transform/drag.ts
Normal file
73
blocksuite/framework/std/src/gfx/element-transform/drag.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxBlockComponent } from '../../view';
|
||||
import type { GfxModel } from '../model/model';
|
||||
import type { GfxElementModelView } from '../view/view';
|
||||
|
||||
export type DragInitializationOption = {
|
||||
movingElements: GfxModel[];
|
||||
event: PointerEvent | MouseEvent;
|
||||
onDragEnd?: () => void;
|
||||
};
|
||||
|
||||
export type DragExtensionInitializeContext = {
|
||||
/**
|
||||
* The elements that are being dragged.
|
||||
* The extension can modify this array to add or remove dragging elements.
|
||||
*/
|
||||
elements: GfxModel[];
|
||||
|
||||
/**
|
||||
* Prevent the default drag behavior. The following drag events will not be triggered.
|
||||
*/
|
||||
preventDefault: () => void;
|
||||
|
||||
/**
|
||||
* The start position of the drag in model space.
|
||||
*/
|
||||
dragStartPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ExtensionBaseEvent = {
|
||||
/**
|
||||
* The elements that respond to the event.
|
||||
*/
|
||||
elements: {
|
||||
view: GfxBlockComponent | GfxElementModelView;
|
||||
originalBound: Bound;
|
||||
model: GfxModel;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* The mouse event
|
||||
*/
|
||||
event: PointerEvent;
|
||||
|
||||
/**
|
||||
* The start position of the drag in model space.
|
||||
*/
|
||||
dragStartPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The last position of the drag in model space.
|
||||
*/
|
||||
dragLastPos: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ExtensionDragStartContext = ExtensionBaseEvent;
|
||||
|
||||
export type ExtensionDragMoveContext = ExtensionBaseEvent & {
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
export type ExtensionDragEndContext = ExtensionDragMoveContext;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
import type { PointerEventState } from '../../../event';
|
||||
import type { GfxController } from '../..';
|
||||
import type { GfxElementModelView } from '../../view/view';
|
||||
|
||||
export class CanvasEventHandler {
|
||||
private _currentStackedElm: GfxElementModelView[] = [];
|
||||
|
||||
private _callInReverseOrder(
|
||||
callback: (view: GfxElementModelView) => void,
|
||||
arr = this._currentStackedElm
|
||||
) {
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const view = arr[i];
|
||||
|
||||
callback(view);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private readonly gfx: GfxController) {}
|
||||
|
||||
click(_evt: PointerEventState): void {
|
||||
last(this._currentStackedElm)?.dispatch('click', _evt);
|
||||
}
|
||||
|
||||
dblClick(_evt: PointerEventState): void {
|
||||
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
|
||||
}
|
||||
|
||||
pointerDown(_evt: PointerEventState): void {
|
||||
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
|
||||
}
|
||||
|
||||
pointerMove(_evt: PointerEventState): void {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y);
|
||||
const hoveredElmViews = this.gfx.grid
|
||||
.search(new Bound(x - 5, y - 5, 10, 10), {
|
||||
filter: ['canvas', 'local'],
|
||||
})
|
||||
.map(model => this.gfx.view.get(model)) as GfxElementModelView[];
|
||||
const currentStackedViews = new Set(this._currentStackedElm);
|
||||
const visited = new Set<GfxElementModelView>();
|
||||
|
||||
this._callInReverseOrder(view => {
|
||||
if (currentStackedViews.has(view)) {
|
||||
visited.add(view);
|
||||
view.dispatch('pointermove', _evt);
|
||||
} else {
|
||||
view.dispatch('pointerenter', _evt);
|
||||
}
|
||||
}, hoveredElmViews);
|
||||
this._callInReverseOrder(
|
||||
view => !visited.has(view) && view.dispatch('pointerleave', _evt)
|
||||
);
|
||||
this._currentStackedElm = hoveredElmViews;
|
||||
}
|
||||
|
||||
pointerUp(_evt: PointerEventState): void {
|
||||
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
type Container,
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Bound, Point } from '@blocksuite/global/gfx';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { PointerEventState } from '../../event/state/pointer.js';
|
||||
import { type GfxController } from '../controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import { GfxControllerIdentifier } from '../identifiers.js';
|
||||
import type { GfxModel } from '../model/model.js';
|
||||
import { type SupportedEvent } from '../view/view.js';
|
||||
import type {
|
||||
DragExtensionInitializeContext,
|
||||
DragInitializationOption,
|
||||
ExtensionDragEndContext,
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from './drag.js';
|
||||
import { CanvasEventHandler } from './extension/canvas-event-handler.js';
|
||||
|
||||
type ExtensionPointerHandler = Exclude<
|
||||
SupportedEvent,
|
||||
'pointerleave' | 'pointerenter'
|
||||
>;
|
||||
|
||||
export const TransformManagerIdentifier = GfxExtensionIdentifier(
|
||||
'element-transform-manager'
|
||||
) as ServiceIdentifier<ElementTransformManager>;
|
||||
|
||||
const CAMEL_CASE_MAP: {
|
||||
[key in ExtensionPointerHandler]: keyof CanvasEventHandler;
|
||||
} = {
|
||||
click: 'click',
|
||||
dblclick: 'dblClick',
|
||||
pointerdown: 'pointerDown',
|
||||
pointermove: 'pointerMove',
|
||||
pointerup: 'pointerUp',
|
||||
};
|
||||
|
||||
export class ElementTransformManager extends GfxExtension {
|
||||
static override key = 'element-transform-manager';
|
||||
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
private canvasEventHandler = new CanvasEventHandler(this.gfx);
|
||||
|
||||
override mounted(): void {
|
||||
this.canvasEventHandler = new CanvasEventHandler(this.gfx);
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
|
||||
get transformExtensions() {
|
||||
return this.std.provider.getAll(TransformExtensionIdentifier);
|
||||
}
|
||||
|
||||
get keyboard() {
|
||||
return this.gfx.keyboard;
|
||||
}
|
||||
|
||||
private _safeExecute(fn: () => void, errorMessage: string) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
console.error(errorMessage, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the event to canvas elements
|
||||
* @param eventName
|
||||
* @param evt
|
||||
*/
|
||||
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
|
||||
const handlerName = CAMEL_CASE_MAP[eventName];
|
||||
|
||||
this.canvasEventHandler[handlerName](evt);
|
||||
|
||||
const extension = this.transformExtensions;
|
||||
|
||||
extension.forEach(ext => {
|
||||
ext[handlerName]?.(evt);
|
||||
});
|
||||
}
|
||||
|
||||
dispatchOnSelected(evt: PointerEventState) {
|
||||
const { raw } = evt;
|
||||
const { gfx } = this;
|
||||
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
|
||||
const picked = this.gfx.getElementInGroup(x, y);
|
||||
|
||||
const tryGetLockedAncestor = (e: GfxModel) => {
|
||||
if (e?.isLockedByAncestor()) {
|
||||
return e.groups.findLast(group => group.isLocked()) ?? e;
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
if (picked) {
|
||||
const lockedElement = tryGetLockedAncestor(picked);
|
||||
const multiSelect = raw.shiftKey;
|
||||
const view = gfx.view.get(lockedElement);
|
||||
const context = {
|
||||
selected: multiSelect ? !gfx.selection.has(picked.id) : true,
|
||||
multiSelect,
|
||||
event: raw,
|
||||
position: Point.from([x, y]),
|
||||
fallback: lockedElement !== picked,
|
||||
};
|
||||
|
||||
view?.onSelected(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
initializeDrag(options: DragInitializationOption) {
|
||||
let cancelledByExt = false;
|
||||
|
||||
const context: DragExtensionInitializeContext = {
|
||||
/**
|
||||
* The elements that are being dragged
|
||||
*/
|
||||
elements: options.movingElements,
|
||||
|
||||
preventDefault: () => {
|
||||
cancelledByExt = true;
|
||||
},
|
||||
|
||||
dragStartPos: Point.from(
|
||||
this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
options.event.x,
|
||||
options.event.y,
|
||||
])
|
||||
),
|
||||
};
|
||||
const extension = this.transformExtensions;
|
||||
const activeExtensionHandlers = Array.from(
|
||||
extension.values().map(ext => {
|
||||
return ext.onDragInitialize(context);
|
||||
})
|
||||
);
|
||||
|
||||
if (cancelledByExt) {
|
||||
activeExtensionHandlers.forEach(handler => handler.clear?.());
|
||||
return;
|
||||
}
|
||||
|
||||
const host = this.std.host;
|
||||
const { event } = options;
|
||||
const internal = {
|
||||
elements: context.elements.map(model => {
|
||||
return {
|
||||
view: this.gfx.view.get(model)!,
|
||||
originalBound: Bound.deserialize(model.xywh),
|
||||
model: model,
|
||||
};
|
||||
}),
|
||||
dragStartPos: Point.from(
|
||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||
),
|
||||
};
|
||||
let dragLastPos = internal.dragStartPos;
|
||||
let lastEvent = event;
|
||||
|
||||
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
|
||||
onDragMove(lastEvent as PointerEvent);
|
||||
});
|
||||
const onDragMove = (event: PointerEvent) => {
|
||||
dragLastPos = Point.from(
|
||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||
);
|
||||
|
||||
const moveContext: ExtensionDragMoveContext = {
|
||||
...internal,
|
||||
event,
|
||||
dragLastPos,
|
||||
dx: dragLastPos.x - internal.dragStartPos.x,
|
||||
dy: dragLastPos.y - internal.dragStartPos.y,
|
||||
};
|
||||
|
||||
// If shift key is pressed, restrict the movement to one direction
|
||||
if (this.keyboard.shiftKey$.peek()) {
|
||||
const angle = Math.abs(Math.atan2(moveContext.dy, moveContext.dx));
|
||||
const direction =
|
||||
angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'dy' : 'dx';
|
||||
|
||||
moveContext[direction] = 0;
|
||||
}
|
||||
|
||||
this._safeExecute(() => {
|
||||
activeExtensionHandlers.forEach(handler =>
|
||||
handler.onDragMove?.(moveContext)
|
||||
);
|
||||
}, 'Error while executing extension `onDragMove`');
|
||||
|
||||
internal.elements.forEach(element => {
|
||||
const { view, originalBound } = element;
|
||||
|
||||
view.onDragMove({
|
||||
currentBound: originalBound,
|
||||
dx: moveContext.dx,
|
||||
dy: moveContext.dy,
|
||||
elements: internal.elements,
|
||||
});
|
||||
});
|
||||
};
|
||||
const onDragEnd = (event: PointerEvent) => {
|
||||
host.removeEventListener('pointermove', onDragMove, false);
|
||||
host.removeEventListener('pointerup', onDragEnd, false);
|
||||
viewportWatcher.unsubscribe();
|
||||
|
||||
dragLastPos = Point.from(
|
||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||
);
|
||||
const endContext: ExtensionDragEndContext = {
|
||||
...internal,
|
||||
event,
|
||||
dragLastPos,
|
||||
dx: dragLastPos.x - internal.dragStartPos.x,
|
||||
dy: dragLastPos.y - internal.dragStartPos.y,
|
||||
};
|
||||
|
||||
this._safeExecute(() => {
|
||||
activeExtensionHandlers.forEach(handler =>
|
||||
handler.onDragEnd?.(endContext)
|
||||
);
|
||||
}, 'Error while executing extension `onDragEnd` handler');
|
||||
|
||||
this.std.store.transact(() => {
|
||||
internal.elements.forEach(element => {
|
||||
const { view, originalBound } = element;
|
||||
|
||||
view.onDragEnd({
|
||||
currentBound: originalBound.moveDelta(endContext.dx, endContext.dy),
|
||||
dx: endContext.dx,
|
||||
dy: endContext.dy,
|
||||
elements: internal.elements,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this._safeExecute(() => {
|
||||
activeExtensionHandlers.forEach(handler => handler.clear?.());
|
||||
}, 'Error while executing extension `clear` handler');
|
||||
|
||||
options.onDragEnd?.();
|
||||
};
|
||||
const listenEvent = () => {
|
||||
host.addEventListener('pointermove', onDragMove, false);
|
||||
host.addEventListener('pointerup', onDragEnd, false);
|
||||
};
|
||||
const dragStart = () => {
|
||||
internal.elements.forEach(({ view, originalBound }) => {
|
||||
view.onDragStart({
|
||||
currentBound: originalBound,
|
||||
elements: internal.elements,
|
||||
});
|
||||
});
|
||||
|
||||
const dragStartContext: ExtensionDragStartContext = {
|
||||
...internal,
|
||||
event: event as PointerEvent,
|
||||
dragLastPos,
|
||||
};
|
||||
|
||||
this._safeExecute(() => {
|
||||
activeExtensionHandlers.forEach(handler =>
|
||||
handler.onDragStart?.(dragStartContext)
|
||||
);
|
||||
}, 'Error while executing extension `onDragStart` handler');
|
||||
};
|
||||
|
||||
listenEvent();
|
||||
dragStart();
|
||||
}
|
||||
}
|
||||
|
||||
export const TransformExtensionIdentifier =
|
||||
createIdentifier<TransformExtension>('element-transform-extension');
|
||||
|
||||
export class TransformExtension extends Extension {
|
||||
static key: string;
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
constructor(protected readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
mounted() {}
|
||||
|
||||
unmounted() {}
|
||||
|
||||
click(_: PointerEventState) {}
|
||||
|
||||
dblClick(_: PointerEventState) {}
|
||||
|
||||
pointerDown(_: PointerEventState) {}
|
||||
|
||||
pointerMove(_: PointerEventState) {}
|
||||
|
||||
pointerUp(_: PointerEventState) {}
|
||||
|
||||
onDragInitialize(_: DragExtensionInitializeContext): {
|
||||
onDragStart?: (context: ExtensionDragStartContext) => void;
|
||||
onDragMove?: (context: ExtensionDragMoveContext) => void;
|
||||
onDragEnd?: (context: ExtensionDragEndContext) => void;
|
||||
clear?: () => void;
|
||||
} {
|
||||
return {};
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'key is not defined in the TransformExtension'
|
||||
);
|
||||
}
|
||||
|
||||
di.add(
|
||||
this as unknown as { new (gfx: GfxController): TransformExtension },
|
||||
[GfxControllerIdentifier]
|
||||
);
|
||||
di.addImpl(TransformExtensionIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Bound, IPoint } from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxBlockComponent } from '../../view';
|
||||
import type { GfxModel } from '../model/model';
|
||||
import type { GfxElementModelView } from '../view/view';
|
||||
|
||||
export type DragStartContext = {
|
||||
/**
|
||||
* The elements that are being dragged
|
||||
*/
|
||||
elements: {
|
||||
view: GfxBlockComponent | GfxElementModelView;
|
||||
originalBound: Bound;
|
||||
model: GfxModel;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* The bound of element when drag starts
|
||||
*/
|
||||
currentBound: Bound;
|
||||
};
|
||||
|
||||
export type DragMoveContext = DragStartContext & {
|
||||
/**
|
||||
* The delta x of current drag position compared to the start position in model coordinate.
|
||||
*/
|
||||
dx: number;
|
||||
|
||||
/**
|
||||
* The delta y of current drag position compared to the start position in model coordinate.
|
||||
*/
|
||||
dy: number;
|
||||
};
|
||||
|
||||
export type DragEndContext = DragMoveContext;
|
||||
|
||||
export type SelectedContext = {
|
||||
/**
|
||||
* The selected state of the element
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* Whether is multi-select, usually triggered by shift key
|
||||
*/
|
||||
multiSelect: boolean;
|
||||
|
||||
/**
|
||||
* The pointer event that triggers the selection
|
||||
*/
|
||||
event: PointerEvent;
|
||||
|
||||
/**
|
||||
* The model position of the event pointer
|
||||
*/
|
||||
position: IPoint;
|
||||
|
||||
/**
|
||||
* If the current selection is a fallback selection, like selecting the element inside a group, the group will be selected instead
|
||||
*/
|
||||
fallback: boolean;
|
||||
};
|
||||
|
||||
export type GfxViewTransformInterface = {
|
||||
onDragStart: (context: DragStartContext) => void;
|
||||
onDragMove: (context: DragMoveContext) => void;
|
||||
onDragEnd: (context: DragEndContext) => void;
|
||||
onRotate: (context: {}) => void;
|
||||
onResize: (context: {}) => void;
|
||||
|
||||
/**
|
||||
* When the element is selected by the pointer
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
onSelected: (context: SelectedContext) => void;
|
||||
};
|
||||
53
blocksuite/framework/std/src/gfx/extension.ts
Normal file
53
blocksuite/framework/std/src/gfx/extension.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { GfxController } from './controller.js';
|
||||
import { GfxControllerIdentifier } from './identifiers.js';
|
||||
|
||||
export const GfxExtensionIdentifier =
|
||||
createIdentifier<GfxExtension>('GfxExtension');
|
||||
|
||||
export const GfxClassExtenderIdentifier = createIdentifier<{
|
||||
extendFn: (gfx: GfxController) => void;
|
||||
}>('GfxClassExtender');
|
||||
|
||||
export abstract class GfxExtension extends Extension {
|
||||
static key: string;
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
constructor(protected readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
// This method is used to extend the GfxController
|
||||
static extendGfx(_: GfxController) {}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'key is not defined in the GfxExtension'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(GfxClassExtenderIdentifier(this.key), {
|
||||
extendFn: this.extendGfx,
|
||||
});
|
||||
|
||||
di.add(this as unknown as { new (gfx: GfxController): GfxExtension }, [
|
||||
GfxControllerIdentifier,
|
||||
]);
|
||||
|
||||
di.addImpl(GfxExtensionIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
|
||||
mounted() {}
|
||||
|
||||
unmounted() {}
|
||||
}
|
||||
507
blocksuite/framework/std/src/gfx/grid.ts
Normal file
507
blocksuite/framework/std/src/gfx/grid.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { IBound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
getBoundWithRotation,
|
||||
intersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { compare } from '../utils/layer.js';
|
||||
import { GfxExtension } from './extension.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||
import { GfxLocalElementModel } from './model/surface/local-element-model.js';
|
||||
import { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
|
||||
function getGridIndex(val: number) {
|
||||
return Math.ceil(val / DEFAULT_GRID_SIZE) - 1;
|
||||
}
|
||||
|
||||
function rangeFromBound(a: IBound): number[] {
|
||||
if (a.rotate) a = getBoundWithRotation(a);
|
||||
const minRow = getGridIndex(a.x);
|
||||
const maxRow = getGridIndex(a.x + a.w);
|
||||
const minCol = getGridIndex(a.y);
|
||||
const maxCol = getGridIndex(a.y + a.h);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
function rangeFromElement(ele: GfxModel | GfxLocalElementModel): number[] {
|
||||
const bound = ele.elementBound;
|
||||
|
||||
bound.w += ele.responseExtension[0] * 2;
|
||||
bound.h += ele.responseExtension[1] * 2;
|
||||
bound.x -= ele.responseExtension[0];
|
||||
bound.y -= ele.responseExtension[1];
|
||||
|
||||
const minRow = getGridIndex(bound.x);
|
||||
const maxRow = getGridIndex(bound.maxX);
|
||||
const minCol = getGridIndex(bound.y);
|
||||
const maxCol = getGridIndex(bound.maxY);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
function rangeFromElementExternal(ele: GfxModel): number[] | null {
|
||||
if (!ele.externalXYWH) return null;
|
||||
|
||||
const bound = Bound.deserialize(ele.externalXYWH);
|
||||
const minRow = getGridIndex(bound.x);
|
||||
const maxRow = getGridIndex(bound.maxX);
|
||||
const minCol = getGridIndex(bound.y);
|
||||
const maxCol = getGridIndex(bound.maxY);
|
||||
return [minRow, maxRow, minCol, maxCol];
|
||||
}
|
||||
|
||||
export const DEFAULT_GRID_SIZE = 3000;
|
||||
|
||||
const typeFilters = {
|
||||
block: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxBlockElementModel,
|
||||
canvas: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxPrimitiveElementModel,
|
||||
local: (model: GfxModel | GfxLocalElementModel) =>
|
||||
model instanceof GfxLocalElementModel,
|
||||
};
|
||||
|
||||
type FilterFunc = (model: GfxModel | GfxLocalElementModel) => boolean;
|
||||
|
||||
export class GridManager extends GfxExtension {
|
||||
static override key = 'grid';
|
||||
|
||||
private readonly _elementToGrids = new Map<
|
||||
GfxModel | GfxLocalElementModel,
|
||||
Set<Set<GfxModel | GfxLocalElementModel>>
|
||||
>();
|
||||
|
||||
private readonly _externalElementToGrids = new Map<
|
||||
GfxModel,
|
||||
Set<Set<GfxModel>>
|
||||
>();
|
||||
|
||||
private readonly _externalGrids = new Map<string, Set<GfxModel>>();
|
||||
|
||||
private readonly _grids = new Map<
|
||||
string,
|
||||
Set<GfxModel | GfxLocalElementModel>
|
||||
>();
|
||||
|
||||
get isEmpty() {
|
||||
return this._grids.size === 0;
|
||||
}
|
||||
|
||||
private _addToExternalGrids(element: GfxModel) {
|
||||
const range = rangeFromElementExternal(element);
|
||||
|
||||
if (!range) {
|
||||
this._removeFromExternalGrids(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const [minRow, maxRow, minCol, maxCol] = range;
|
||||
const grids = new Set<Set<GfxModel>>();
|
||||
this._externalElementToGrids.set(element, grids);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
let grid = this._getExternalGrid(i, j);
|
||||
if (!grid) {
|
||||
grid = this._createExternalGrid(i, j);
|
||||
}
|
||||
grid.add(element);
|
||||
grids.add(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _createExternalGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
const elements = new Set<GfxModel>();
|
||||
this._externalGrids.set(id, elements);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _createGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
const elements = new Set<GfxModel>();
|
||||
this._grids.set(id, elements);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _getExternalGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
return this._externalGrids.get(id);
|
||||
}
|
||||
|
||||
private _getGrid(row: number, col: number) {
|
||||
const id = row + '|' + col;
|
||||
return this._grids.get(id);
|
||||
}
|
||||
|
||||
private _removeFromExternalGrids(element: GfxModel) {
|
||||
const grids = this._externalElementToGrids.get(element);
|
||||
if (grids) {
|
||||
for (const grid of grids) {
|
||||
grid.delete(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _searchExternal(
|
||||
bound: IBound,
|
||||
options: { filterFunc: FilterFunc; strict: boolean }
|
||||
): Set<GfxModel> {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const results = new Set<GfxModel>();
|
||||
const b = Bound.from(bound);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getExternalGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
|
||||
for (const element of gridElements) {
|
||||
const externalBound = element.externalBound;
|
||||
if (
|
||||
options.filterFunc(element) &&
|
||||
externalBound &&
|
||||
(options.strict
|
||||
? b.contains(externalBound)
|
||||
: intersects(externalBound, bound))
|
||||
) {
|
||||
results.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private _toFilterFunc(filters: (keyof typeof typeFilters | FilterFunc)[]) {
|
||||
const filterFuncs: FilterFunc[] = filters.map(filter => {
|
||||
if (typeof filter === 'function') {
|
||||
return filter;
|
||||
}
|
||||
return typeFilters[filter];
|
||||
});
|
||||
|
||||
return (model: GfxModel | GfxLocalElementModel) =>
|
||||
filterFuncs.some(filter => filter(model));
|
||||
}
|
||||
|
||||
add(element: GfxModel | GfxLocalElementModel) {
|
||||
if (!(element instanceof GfxLocalElementModel)) {
|
||||
this._addToExternalGrids(element);
|
||||
}
|
||||
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromElement(element);
|
||||
const grids = new Set<Set<GfxModel | GfxLocalElementModel>>();
|
||||
this._elementToGrids.set(element, grids);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
let grid = this._getGrid(i, j);
|
||||
if (!grid) {
|
||||
grid = this._createGrid(i, j);
|
||||
}
|
||||
grid.add(element);
|
||||
grids.add(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boundHasChanged(a: IBound, b: IBound) {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(a);
|
||||
const [minRow2, maxRow2, minCol2, maxCol2] = rangeFromBound(b);
|
||||
return (
|
||||
minRow !== minRow2 ||
|
||||
maxRow !== maxRow2 ||
|
||||
minCol !== minCol2 ||
|
||||
maxCol !== maxCol2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param bound
|
||||
* @param strict
|
||||
* @param reverseChecking If true, check if the bound is inside the elements instead of checking if the elements are inside the bound
|
||||
* @returns
|
||||
*/
|
||||
has(
|
||||
bound: IBound,
|
||||
strict: boolean = false,
|
||||
reverseChecking: boolean = false,
|
||||
filter?: (model: GfxModel | GfxLocalElementModel) => boolean
|
||||
) {
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const b = Bound.from(bound);
|
||||
const check = reverseChecking
|
||||
? (target: Bound) => {
|
||||
return strict ? target.contains(b) : intersects(b, target);
|
||||
}
|
||||
: (target: Bound) => {
|
||||
return strict ? b.contains(target) : intersects(target, b);
|
||||
};
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
for (const element of gridElements) {
|
||||
if ((!filter || filter(element)) && check(element.elementBound)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
remove(element: GfxModel | GfxLocalElementModel) {
|
||||
const grids = this._elementToGrids.get(element);
|
||||
if (grids) {
|
||||
for (const grid of grids) {
|
||||
grid.delete(element);
|
||||
}
|
||||
}
|
||||
this._elementToGrids.delete(element);
|
||||
|
||||
if (!(element instanceof GfxLocalElementModel)) {
|
||||
this._removeFromExternalGrids(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for elements in a bound.
|
||||
* @param bound
|
||||
* @param options
|
||||
*/
|
||||
search<T extends keyof typeof typeFilters>(
|
||||
bound: IBound,
|
||||
options?: {
|
||||
/**
|
||||
* If true, only return elements that are completely inside the bound.
|
||||
* Default is false.
|
||||
*/
|
||||
strict?: boolean;
|
||||
/**
|
||||
* If true, return a set of elements instead of an array
|
||||
*/
|
||||
useSet?: false;
|
||||
/**
|
||||
* Use this to filter the elements, if not provided, it will return blocks and canvas elements by default
|
||||
*/
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
}
|
||||
): T extends 'local'[] ? (GfxModel | GfxLocalElementModel)[] : GfxModel[];
|
||||
search<T extends keyof typeof typeFilters>(
|
||||
bound: IBound,
|
||||
options: {
|
||||
strict?: boolean | undefined;
|
||||
useSet: true;
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
}
|
||||
): T extends 'local'[] ? Set<GfxModel | GfxLocalElementModel> : Set<GfxModel>;
|
||||
search<T extends keyof typeof typeFilters>(
|
||||
bound: IBound,
|
||||
options: {
|
||||
strict?: boolean;
|
||||
useSet?: boolean;
|
||||
filter?: (T | FilterFunc)[] | FilterFunc;
|
||||
} = {
|
||||
useSet: false,
|
||||
}
|
||||
):
|
||||
| (GfxModel | GfxLocalElementModel)[]
|
||||
| Set<GfxModel | GfxLocalElementModel> {
|
||||
const strict = options.strict ?? false;
|
||||
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
|
||||
const b = Bound.from(bound);
|
||||
const returnSet = options.useSet ?? false;
|
||||
const filterFunc =
|
||||
(Array.isArray(options.filter)
|
||||
? this._toFilterFunc(options.filter)
|
||||
: options.filter) ?? this._toFilterFunc(['canvas', 'block']);
|
||||
const results: Set<GfxModel | GfxLocalElementModel> = this._searchExternal(
|
||||
bound,
|
||||
{
|
||||
filterFunc,
|
||||
strict,
|
||||
}
|
||||
);
|
||||
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minCol; j <= maxCol; j++) {
|
||||
const gridElements = this._getGrid(i, j);
|
||||
if (!gridElements) continue;
|
||||
for (const element of gridElements) {
|
||||
if (
|
||||
!(element as GfxPrimitiveElementModel).hidden &&
|
||||
filterFunc(element) &&
|
||||
(strict
|
||||
? b.contains(element.elementBound)
|
||||
: intersects(element.responseBound, b))
|
||||
) {
|
||||
results.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (returnSet) return results;
|
||||
|
||||
// sort elements in set based on index
|
||||
const sorted = Array.from(results).sort(compare);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
update(element: GfxModel | GfxLocalElementModel) {
|
||||
this.remove(element);
|
||||
this.add(element);
|
||||
}
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const disposables = this._disposables;
|
||||
const { store } = this.std;
|
||||
const canBeRenderedAsGfxBlock = (
|
||||
block: BlockModel
|
||||
): block is GfxBlockElementModel => {
|
||||
return (
|
||||
block instanceof GfxBlockElementModel &&
|
||||
(block.parent?.role === 'root' ||
|
||||
block.parent instanceof SurfaceBlockModel)
|
||||
);
|
||||
};
|
||||
|
||||
disposables.add(
|
||||
store.slots.blockUpdated.subscribe(payload => {
|
||||
if (payload.type === 'add' && canBeRenderedAsGfxBlock(payload.model)) {
|
||||
this.add(payload.model);
|
||||
}
|
||||
|
||||
if (payload.type === 'update') {
|
||||
const model = store.getBlock(payload.id)
|
||||
?.model as GfxBlockElementModel;
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.props.key === 'xywh' && canBeRenderedAsGfxBlock(model)) {
|
||||
this.update(
|
||||
store.getBlock(payload.id)?.model as GfxBlockElementModel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.type === 'delete' &&
|
||||
payload.model instanceof GfxBlockElementModel
|
||||
) {
|
||||
this.remove(payload.model);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
Object.values(store.blocks.peek()).forEach(block => {
|
||||
if (canBeRenderedAsGfxBlock(block.model)) {
|
||||
this.add(block.model);
|
||||
}
|
||||
});
|
||||
|
||||
const watchSurface = (surface: SurfaceBlockModel) => {
|
||||
let lastChildMap = new Map(surface.childMap.peek());
|
||||
disposables.add(
|
||||
surface.childMap.subscribe(val => {
|
||||
val.forEach((_, id) => {
|
||||
if (lastChildMap.has(id)) {
|
||||
lastChildMap.delete(id);
|
||||
return;
|
||||
}
|
||||
});
|
||||
lastChildMap.forEach((_, id) => {
|
||||
const block = store.getBlock(id);
|
||||
if (block?.model) {
|
||||
this.remove(block.model as GfxBlockElementModel);
|
||||
}
|
||||
});
|
||||
lastChildMap = new Map(val);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementAdded.subscribe(payload => {
|
||||
this.add(surface.getElementById(payload.id)!);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementRemoved.subscribe(payload => {
|
||||
this.remove(payload.model);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.elementUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.props['xywh'] ||
|
||||
payload.props['externalXYWH'] ||
|
||||
payload.props['responseExtension']
|
||||
) {
|
||||
this.update(surface.getElementById(payload.id)!);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementAdded.subscribe(elm => {
|
||||
this.add(elm);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementUpdated.subscribe(payload => {
|
||||
if (payload.props['xywh'] || payload.props['responseExtension']) {
|
||||
this.update(payload.model);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
surface.localElementDeleted.subscribe(elm => {
|
||||
this.remove(elm);
|
||||
})
|
||||
);
|
||||
|
||||
surface.elementModels.forEach(model => {
|
||||
this.add(model);
|
||||
});
|
||||
surface.localElementModels.forEach(model => {
|
||||
this.add(model);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.gfx.surface) {
|
||||
watchSurface(this.gfx.surface);
|
||||
} else {
|
||||
disposables.add(
|
||||
this.gfx.surface$.subscribe(surface => {
|
||||
if (surface) {
|
||||
watchSurface(surface);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
blocksuite/framework/std/src/gfx/identifiers.ts
Normal file
10
blocksuite/framework/std/src/gfx/identifiers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import { LifeCycleWatcherIdentifier } from '../identifier.js';
|
||||
import type { GfxController } from './controller.js';
|
||||
|
||||
export const gfxControllerKey = 'GfxController';
|
||||
|
||||
export const GfxControllerIdentifier = LifeCycleWatcherIdentifier(
|
||||
gfxControllerKey
|
||||
) as ServiceIdentifier<GfxController>;
|
||||
108
blocksuite/framework/std/src/gfx/index.ts
Normal file
108
blocksuite/framework/std/src/gfx/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export { generateKeyBetweenV2 } from '../utils/fractional-indexing.js';
|
||||
export {
|
||||
compare as compareLayer,
|
||||
renderableInEdgeless,
|
||||
SortOrder,
|
||||
} from '../utils/layer.js';
|
||||
export {
|
||||
canSafeAddToContainer,
|
||||
descendantElementsImpl,
|
||||
getTopElements,
|
||||
hasDescendantElementImpl,
|
||||
} from '../utils/tree.js';
|
||||
export { GfxController } from './controller.js';
|
||||
export type { CursorType, StandardCursor } from './cursor.js';
|
||||
export type {
|
||||
DragExtensionInitializeContext,
|
||||
DragInitializationOption,
|
||||
ExtensionDragEndContext,
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from './element-transform/drag.js';
|
||||
export { CanvasEventHandler } from './element-transform/extension/canvas-event-handler.js';
|
||||
export {
|
||||
ElementTransformManager,
|
||||
TransformExtension,
|
||||
TransformExtensionIdentifier,
|
||||
TransformManagerIdentifier,
|
||||
} from './element-transform/transform-manager.js';
|
||||
export type {
|
||||
DragEndContext,
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
} from './element-transform/view-transform.js';
|
||||
export { type SelectedContext } from './element-transform/view-transform.js';
|
||||
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
|
||||
export { GridManager } from './grid.js';
|
||||
export { GfxControllerIdentifier } from './identifiers.js';
|
||||
export { LayerManager, type ReorderingDirection } from './layer.js';
|
||||
export type {
|
||||
GfxCompatibleInterface,
|
||||
GfxElementGeometry,
|
||||
GfxGroupCompatibleInterface,
|
||||
PointTestOptions,
|
||||
} from './model/base.js';
|
||||
export {
|
||||
gfxGroupCompatibleSymbol,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from './model/base.js';
|
||||
export {
|
||||
GfxBlockElementModel,
|
||||
type GfxCommonBlockProps,
|
||||
GfxCompatibleBlockModel as GfxCompatible,
|
||||
type GfxCompatibleProps,
|
||||
} from './model/gfx-block-model.js';
|
||||
export { type GfxModel } from './model/model.js';
|
||||
export {
|
||||
convert,
|
||||
convertProps,
|
||||
derive,
|
||||
field,
|
||||
getDerivedProps,
|
||||
getFieldPropsSet,
|
||||
initializeObservers,
|
||||
initializeWatchers,
|
||||
local,
|
||||
observe,
|
||||
updateDerivedProps,
|
||||
watch,
|
||||
} from './model/surface/decorators/index.js';
|
||||
export {
|
||||
type BaseElementProps,
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
type SerializedElement,
|
||||
} from './model/surface/element-model.js';
|
||||
export {
|
||||
GfxLocalElementModel,
|
||||
prop,
|
||||
} from './model/surface/local-element-model.js';
|
||||
export {
|
||||
SURFACE_TEXT_UNIQ_IDENTIFIER,
|
||||
SURFACE_YMAP_UNIQ_IDENTIFIER,
|
||||
SurfaceBlockModel,
|
||||
type SurfaceBlockProps,
|
||||
type SurfaceMiddleware,
|
||||
} from './model/surface/surface-model.js';
|
||||
export { GfxSelectionManager } from './selection.js';
|
||||
export {
|
||||
SurfaceMiddlewareBuilder,
|
||||
SurfaceMiddlewareExtension,
|
||||
} from './surface-middleware.js';
|
||||
export {
|
||||
BaseTool,
|
||||
type GfxToolsFullOption,
|
||||
type GfxToolsFullOptionValue,
|
||||
type GfxToolsMap,
|
||||
type GfxToolsOption,
|
||||
} from './tool/tool.js';
|
||||
export { MouseButton, ToolController } from './tool/tool-controller.js';
|
||||
export {
|
||||
type EventsHandlerMap,
|
||||
GfxElementModelView,
|
||||
type SupportedEvent,
|
||||
} from './view/view.js';
|
||||
export { ViewManager } from './view/view-manager.js';
|
||||
export * from './viewport.js';
|
||||
export { GfxViewportElement } from './viewport-element.js';
|
||||
export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
51
blocksuite/framework/std/src/gfx/keyboard.ts
Normal file
51
blocksuite/framework/std/src/gfx/keyboard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
|
||||
export class KeyboardController {
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
shiftKey$ = new Signal<boolean>(false);
|
||||
|
||||
spaceKey$ = new Signal<boolean>(false);
|
||||
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
this._init();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this._disposable.add(
|
||||
this._listenKeyboard('keydown', evt => {
|
||||
this.shiftKey$.value = evt.shiftKey && evt.key === 'Shift';
|
||||
this.spaceKey$.value = evt.code === 'Space';
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
this._listenKeyboard('keyup', evt => {
|
||||
this.shiftKey$.value =
|
||||
evt.shiftKey && evt.key === 'Shift' ? true : false;
|
||||
|
||||
if (evt.code === 'Space') {
|
||||
this.spaceKey$.value = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _listenKeyboard(
|
||||
event: 'keydown' | 'keyup',
|
||||
callback: (keyboardEvt: KeyboardEvent) => void
|
||||
) {
|
||||
document.addEventListener(event, callback, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(event, callback, false);
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
}
|
||||
903
blocksuite/framework/std/src/gfx/layer.ts
Normal file
903
blocksuite/framework/std/src/gfx/layer.ts
Normal file
@@ -0,0 +1,903 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
import last from 'lodash-es/last';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import {
|
||||
compare,
|
||||
getElementIndex,
|
||||
getLayerEndZIndex,
|
||||
insertToOrderedArray,
|
||||
isInRange,
|
||||
removeFromOrderedArray,
|
||||
SortOrder,
|
||||
ungroupIndex,
|
||||
updateLayersZIndex,
|
||||
} from '../utils/layer.js';
|
||||
import { GfxExtension } from './extension.js';
|
||||
import type { GfxController } from './index.js';
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from './model/base.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||
import { GfxLocalElementModel } from './model/surface/local-element-model.js';
|
||||
import { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
|
||||
export type ReorderingDirection = 'front' | 'forward' | 'backward' | 'back';
|
||||
|
||||
type BaseLayer<T> = {
|
||||
set: Set<T>;
|
||||
|
||||
elements: Array<T>;
|
||||
|
||||
/**
|
||||
* fractional indexing range
|
||||
*/
|
||||
indexes: [string, string];
|
||||
};
|
||||
|
||||
export type BlockLayer = BaseLayer<GfxBlockElementModel> & {
|
||||
type: 'block';
|
||||
|
||||
/**
|
||||
* The z-index of the first block in this layer.
|
||||
*
|
||||
* A block layer may contains multiple blocks,
|
||||
* the block should be rendered with this `zIndex` + "its index in the layer" as the z-index property.
|
||||
*/
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type CanvasLayer = BaseLayer<GfxPrimitiveElementModel> & {
|
||||
type: 'canvas';
|
||||
|
||||
/**
|
||||
* The z-index of canvas layer.
|
||||
*
|
||||
* A canvas layer renders all the elements in a single canvas,
|
||||
* this property is used to render the canvas with correct z-index.
|
||||
*/
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type Layer = BlockLayer | CanvasLayer;
|
||||
|
||||
export class LayerManager extends GfxExtension {
|
||||
static override key = 'layerManager';
|
||||
|
||||
static INITIAL_INDEX = 'a0';
|
||||
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
private get _doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
private get _surface() {
|
||||
return this.gfx.surface;
|
||||
}
|
||||
|
||||
blocks: GfxBlockElementModel[] = [];
|
||||
|
||||
canvasElements: GfxPrimitiveElementModel[] = [];
|
||||
|
||||
canvasLayers: {
|
||||
set: Set<GfxPrimitiveElementModel>;
|
||||
/**
|
||||
* fractional index
|
||||
*/
|
||||
indexes: [string, string];
|
||||
/**
|
||||
* z-index, used for actual rendering
|
||||
*/
|
||||
zIndex: number;
|
||||
elements: Array<GfxPrimitiveElementModel>;
|
||||
}[] = [];
|
||||
|
||||
layers: Layer[] = [];
|
||||
|
||||
slots = {
|
||||
layerUpdated: new Subject<{
|
||||
type: 'delete' | 'add' | 'update';
|
||||
initiatingElement: GfxModel | GfxLocalElementModel;
|
||||
}>(),
|
||||
};
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
private _buildCanvasLayers() {
|
||||
const canvasLayers = this.layers
|
||||
.filter<CanvasLayer>(
|
||||
(layer): layer is CanvasLayer => layer.type === 'canvas'
|
||||
)
|
||||
.map(layer => {
|
||||
return {
|
||||
set: layer.set,
|
||||
elements: layer.elements,
|
||||
zIndex: layer.zIndex,
|
||||
indexes: layer.indexes,
|
||||
};
|
||||
}) as LayerManager['canvasLayers'];
|
||||
|
||||
if (!canvasLayers.length || last(this.layers)?.type !== 'canvas') {
|
||||
canvasLayers.push({
|
||||
set: new Set(),
|
||||
elements: [],
|
||||
zIndex: 0,
|
||||
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
|
||||
});
|
||||
}
|
||||
|
||||
this.canvasLayers = canvasLayers;
|
||||
}
|
||||
|
||||
private _getModelType(
|
||||
element: GfxModel | GfxLocalElementModel
|
||||
): 'block' | 'canvas' {
|
||||
return element instanceof GfxLocalElementModel ||
|
||||
element instanceof GfxPrimitiveElementModel
|
||||
? 'canvas'
|
||||
: 'block';
|
||||
}
|
||||
|
||||
private _initLayers() {
|
||||
let blockIdx = 0;
|
||||
let canvasIdx = 0;
|
||||
const layers: LayerManager['layers'] = [];
|
||||
let curLayer: LayerManager['layers'][number] | undefined;
|
||||
let currentCSSZindex = 1;
|
||||
|
||||
const pushCurLayer = () => {
|
||||
if (curLayer) {
|
||||
curLayer.indexes = [
|
||||
getElementIndex(curLayer.elements[0]),
|
||||
getElementIndex(
|
||||
last(curLayer.elements as GfxPrimitiveElementModel[])!
|
||||
),
|
||||
];
|
||||
curLayer.zIndex = currentCSSZindex;
|
||||
layers.push(curLayer as LayerManager['layers'][number]);
|
||||
|
||||
currentCSSZindex +=
|
||||
curLayer.type === 'block' ? curLayer.elements.length : 1;
|
||||
}
|
||||
};
|
||||
const addLayer = (type: 'canvas' | 'block') => {
|
||||
pushCurLayer();
|
||||
curLayer =
|
||||
type === 'canvas'
|
||||
? ({
|
||||
type,
|
||||
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
|
||||
zIndex: 0,
|
||||
set: new Set(),
|
||||
elements: [],
|
||||
bound: new Bound(),
|
||||
} as CanvasLayer)
|
||||
: ({
|
||||
type,
|
||||
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
|
||||
zIndex: 0,
|
||||
set: new Set(),
|
||||
elements: [],
|
||||
} as BlockLayer);
|
||||
};
|
||||
|
||||
while (
|
||||
blockIdx < this.blocks.length ||
|
||||
canvasIdx < this.canvasElements.length
|
||||
) {
|
||||
const curBlock = this.blocks[blockIdx];
|
||||
const curCanvas = this.canvasElements[canvasIdx];
|
||||
|
||||
if (!curBlock && !curCanvas) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!curBlock) {
|
||||
if (curLayer?.type !== 'canvas') {
|
||||
addLayer('canvas');
|
||||
}
|
||||
assertType<CanvasLayer>(curLayer);
|
||||
|
||||
const remains = this.canvasElements.slice(canvasIdx);
|
||||
|
||||
curLayer!.elements = curLayer.elements.concat(remains);
|
||||
remains.forEach(element => (curLayer as CanvasLayer).set.add(element));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!curCanvas) {
|
||||
if (curLayer?.type !== 'block') {
|
||||
addLayer('block');
|
||||
}
|
||||
|
||||
assertType<BlockLayer>(curLayer);
|
||||
|
||||
const remains = this.blocks.slice(blockIdx);
|
||||
|
||||
curLayer.elements = curLayer.elements.concat(remains);
|
||||
remains.forEach(block => (curLayer as BlockLayer).set.add(block));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const order = compare(curBlock, curCanvas);
|
||||
|
||||
switch (order) {
|
||||
case -1:
|
||||
if (curLayer?.type !== 'block') {
|
||||
addLayer('block');
|
||||
}
|
||||
|
||||
assertType<BlockLayer>(curLayer);
|
||||
|
||||
curLayer!.set.add(curBlock);
|
||||
curLayer!.elements.push(curBlock);
|
||||
|
||||
++blockIdx;
|
||||
|
||||
break;
|
||||
case 1:
|
||||
if (curLayer?.type !== 'canvas') {
|
||||
addLayer('canvas');
|
||||
}
|
||||
|
||||
assertType<CanvasLayer>(curLayer);
|
||||
|
||||
curLayer!.set.add(curCanvas);
|
||||
curLayer!.elements.push(curCanvas);
|
||||
|
||||
++canvasIdx;
|
||||
|
||||
break;
|
||||
case 0:
|
||||
if (!curLayer) {
|
||||
addLayer('block');
|
||||
}
|
||||
|
||||
if (curLayer!.type === 'block') {
|
||||
curLayer!.set.add(curBlock);
|
||||
curLayer!.elements.push(curBlock);
|
||||
|
||||
++blockIdx;
|
||||
} else {
|
||||
curLayer!.set.add(curCanvas);
|
||||
curLayer!.elements.push(curCanvas);
|
||||
|
||||
++canvasIdx;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (curLayer && curLayer.elements.length) {
|
||||
pushCurLayer();
|
||||
}
|
||||
|
||||
this.layers = layers;
|
||||
this._surface?.localElementModels.forEach(el => this.add(el));
|
||||
}
|
||||
|
||||
private _insertIntoLayer(target: GfxModel, type: 'block' | 'canvas') {
|
||||
const layers = this.layers;
|
||||
let cur = layers.length - 1;
|
||||
|
||||
const addToLayer = (
|
||||
layer: Layer,
|
||||
element: GfxModel,
|
||||
position: number | 'tail'
|
||||
) => {
|
||||
assertType<CanvasLayer>(layer);
|
||||
assertType<GfxPrimitiveElementModel>(element);
|
||||
|
||||
if (position === 'tail') {
|
||||
layer.elements.push(element);
|
||||
} else {
|
||||
layer.elements.splice(position, 0, element);
|
||||
}
|
||||
|
||||
layer.set.add(element);
|
||||
|
||||
if (
|
||||
position === 'tail' ||
|
||||
position === 0 ||
|
||||
position === layer.elements.length - 1
|
||||
) {
|
||||
layer.indexes = [
|
||||
getElementIndex(layer.elements[0]),
|
||||
getElementIndex(last(layer.elements)!),
|
||||
];
|
||||
}
|
||||
};
|
||||
const createLayer = (
|
||||
type: 'block' | 'canvas',
|
||||
targets: GfxModel[],
|
||||
curZIndex: number
|
||||
): Layer => {
|
||||
const newLayer = {
|
||||
type,
|
||||
set: new Set(targets),
|
||||
indexes: [
|
||||
getElementIndex(targets[0]),
|
||||
getElementIndex(last(targets)!),
|
||||
] as [string, string],
|
||||
zIndex: curZIndex + 1,
|
||||
elements: targets,
|
||||
} as BlockLayer;
|
||||
|
||||
return newLayer as Layer;
|
||||
};
|
||||
|
||||
if (
|
||||
!last(this.layers) ||
|
||||
[SortOrder.AFTER, SortOrder.SAME].includes(
|
||||
compare(
|
||||
target,
|
||||
last(last(this.layers)!.elements as GfxPrimitiveElementModel[])!
|
||||
)
|
||||
)
|
||||
) {
|
||||
const layer = last(this.layers);
|
||||
|
||||
if (layer?.type === type) {
|
||||
addToLayer(layer, target, 'tail');
|
||||
updateLayersZIndex(layers, cur);
|
||||
} else {
|
||||
this.layers.push(
|
||||
createLayer(
|
||||
type,
|
||||
[target],
|
||||
getLayerEndZIndex(layers, layers.length - 1)
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
while (cur > -1) {
|
||||
const layer = layers[cur];
|
||||
const layerElements = layer.elements;
|
||||
|
||||
if (
|
||||
isInRange(
|
||||
[
|
||||
layerElements[0],
|
||||
last(layerElements as GfxPrimitiveElementModel[])!,
|
||||
],
|
||||
target
|
||||
)
|
||||
) {
|
||||
const insertIdx = layerElements.findIndex((_, idx) => {
|
||||
const pre = layerElements[idx - 1];
|
||||
return (
|
||||
compare(target, layerElements[idx]) < 0 &&
|
||||
(!pre || compare(target, pre) >= 0)
|
||||
);
|
||||
});
|
||||
|
||||
if (layer.type === type) {
|
||||
addToLayer(layer, target, insertIdx);
|
||||
updateLayersZIndex(layers, cur);
|
||||
} else {
|
||||
const splicedElements = layer.elements.splice(insertIdx);
|
||||
layer.set = new Set(layer.elements as GfxPrimitiveElementModel[]);
|
||||
|
||||
layers.splice(
|
||||
cur + 1,
|
||||
0,
|
||||
createLayer(layer.type, splicedElements, 1)
|
||||
);
|
||||
layers.splice(cur + 1, 0, createLayer(type, [target], 1));
|
||||
updateLayersZIndex(layers, cur);
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
const nextLayer = layers[cur - 1];
|
||||
|
||||
if (
|
||||
!nextLayer ||
|
||||
compare(
|
||||
target,
|
||||
last(nextLayer.elements as GfxPrimitiveElementModel[])!
|
||||
) >= 0
|
||||
) {
|
||||
if (layer.type === type) {
|
||||
addToLayer(layer, target, 0);
|
||||
updateLayersZIndex(layers, cur);
|
||||
} else {
|
||||
if (nextLayer) {
|
||||
addToLayer(nextLayer, target, 'tail');
|
||||
updateLayersZIndex(layers, cur - 1);
|
||||
} else {
|
||||
layers.unshift(createLayer(type, [target], 1));
|
||||
updateLayersZIndex(layers, 0);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
--cur;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _removeFromLayer(
|
||||
target: GfxModel | GfxLocalElementModel,
|
||||
type: 'block' | 'canvas'
|
||||
) {
|
||||
const layers = this.layers;
|
||||
const index = layers.findIndex(layer => {
|
||||
if (layer.type !== type) return false;
|
||||
|
||||
assertType<CanvasLayer>(layer);
|
||||
assertType<GfxPrimitiveElementModel>(target);
|
||||
|
||||
if (layer.set.has(target)) {
|
||||
layer.set.delete(target);
|
||||
const idx = layer.elements.indexOf(target);
|
||||
if (idx !== -1) {
|
||||
layer.elements.splice(layer.elements.indexOf(target), 1);
|
||||
|
||||
if (layer.elements.length) {
|
||||
layer.indexes = [
|
||||
getElementIndex(layer.elements[0]),
|
||||
getElementIndex(last(layer.elements)!),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
const isDeletedAtEdge = index === 0 || index === layers.length - 1;
|
||||
|
||||
if (layers[index].set.size === 0) {
|
||||
if (isDeletedAtEdge) {
|
||||
layers.splice(index, 1);
|
||||
|
||||
if (layers[index]) {
|
||||
updateLayersZIndex(layers, index);
|
||||
}
|
||||
} else {
|
||||
const lastLayer = layers[index - 1] as CanvasLayer;
|
||||
const nextLayer = layers[index + 1] as CanvasLayer;
|
||||
|
||||
lastLayer.elements = lastLayer.elements.concat(nextLayer.elements);
|
||||
lastLayer.set = new Set(lastLayer.elements);
|
||||
|
||||
layers.splice(index, 2);
|
||||
updateLayersZIndex(layers, index - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateLayersZIndex(layers, index);
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
const elements = (
|
||||
this._doc
|
||||
.getAllModels()
|
||||
.filter(
|
||||
model =>
|
||||
model instanceof GfxBlockElementModel &&
|
||||
(model.parent instanceof SurfaceBlockModel ||
|
||||
model.parent?.role === 'root')
|
||||
) as GfxModel[]
|
||||
).concat(this._surface?.elementModels ?? []);
|
||||
|
||||
this.canvasElements = [];
|
||||
this.blocks = [];
|
||||
|
||||
elements.forEach(element => {
|
||||
if (element instanceof GfxPrimitiveElementModel) {
|
||||
this.canvasElements.push(element);
|
||||
} else {
|
||||
this.blocks.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
this.canvasElements.sort(compare);
|
||||
this.blocks.sort(compare);
|
||||
|
||||
this._initLayers();
|
||||
this._buildCanvasLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a boolean value to indicate whether the layers have been updated
|
||||
*/
|
||||
private _updateLayer(
|
||||
element: GfxModel | GfxLocalElementModel,
|
||||
props?: Record<string, unknown>
|
||||
) {
|
||||
const modelType = this._getModelType(element);
|
||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||
|
||||
const indexChanged = !props || 'index' in props;
|
||||
const childIdsChanged =
|
||||
props && ('childIds' in props || 'childElementIds' in props);
|
||||
const shouldUpdateGroupChildren =
|
||||
isGfxGroupCompatibleModel(element) && (indexChanged || childIdsChanged);
|
||||
const updateArray = (array: GfxModel[], element: GfxModel) => {
|
||||
if (!indexChanged) return;
|
||||
removeFromOrderedArray(array, element);
|
||||
insertToOrderedArray(array, element);
|
||||
};
|
||||
|
||||
if (shouldUpdateGroupChildren) {
|
||||
this._reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLocalElem) {
|
||||
if (modelType === 'canvas') {
|
||||
updateArray(this.canvasElements, element);
|
||||
} else {
|
||||
updateArray(this.blocks, element);
|
||||
}
|
||||
}
|
||||
|
||||
if (indexChanged || childIdsChanged) {
|
||||
this._removeFromLayer(element as GfxModel, modelType);
|
||||
this._insertIntoLayer(element as GfxModel, modelType);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
add(element: GfxModel | GfxLocalElementModel) {
|
||||
const modelType = this._getModelType(element);
|
||||
const isContainer = isGfxGroupCompatibleModel(element);
|
||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||
|
||||
if (isContainer) {
|
||||
element.childElements.forEach(child => {
|
||||
const childModelType = this._getModelType(child);
|
||||
removeFromOrderedArray(
|
||||
childModelType === 'canvas' ? this.canvasElements : this.blocks,
|
||||
child
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isLocalElem) {
|
||||
insertToOrderedArray(
|
||||
modelType === 'canvas' ? this.canvasElements : this.blocks,
|
||||
element
|
||||
);
|
||||
}
|
||||
this._insertIntoLayer(element as GfxModel, modelType);
|
||||
|
||||
if (isContainer) {
|
||||
element.childElements.forEach(child => child && this._updateLayer(child));
|
||||
}
|
||||
this._buildCanvasLayers();
|
||||
this.slots.layerUpdated.next({
|
||||
type: 'add',
|
||||
initiatingElement: element,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass to the `Array.sort` to sort the elements by their index
|
||||
*/
|
||||
compare(a: GfxModel, b: GfxModel) {
|
||||
return compare(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* In some cases, we need to generate a bunch of indexes in advance before acutally adding the elements to the layer manager.
|
||||
* Eg. when importing a template. The `generateIndex` is a function only depends on the current state of the manager.
|
||||
* So we cannot use it because it will always return the same index if the element is not added to manager.
|
||||
*
|
||||
* This function return a index generator that can "remember" the index it generated without actually adding the element to the manager.
|
||||
*
|
||||
* @note The generator cannot work with `group` element.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
createIndexGenerator() {
|
||||
const manager = new LayerManager(this.gfx);
|
||||
manager._reset();
|
||||
|
||||
return () => {
|
||||
const idx = manager.generateIndex();
|
||||
const bound = new Bound(0, 0, 10, 10);
|
||||
|
||||
const mockedFakeElement = {
|
||||
index: idx,
|
||||
type: 'shape',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 10,
|
||||
h: 10,
|
||||
elementBound: bound,
|
||||
xywh: '[0, 0, 10, 10]',
|
||||
get group() {
|
||||
return null;
|
||||
},
|
||||
get groups() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
manager.add(mockedFakeElement as unknown as GfxModel);
|
||||
|
||||
return idx;
|
||||
};
|
||||
}
|
||||
|
||||
delete(element: GfxModel | GfxLocalElementModel) {
|
||||
let deleteType: 'canvas' | 'block' | undefined = undefined;
|
||||
const isGroup = isGfxGroupCompatibleModel(element);
|
||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||
|
||||
if (isGroup) {
|
||||
this._reset();
|
||||
this.slots.layerUpdated.next({
|
||||
type: 'delete',
|
||||
initiatingElement: element as GfxModel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
element instanceof GfxPrimitiveElementModel ||
|
||||
element instanceof GfxLocalElementModel
|
||||
) {
|
||||
deleteType = 'canvas';
|
||||
if (!isLocalElem) {
|
||||
removeFromOrderedArray(this.canvasElements, element);
|
||||
}
|
||||
} else {
|
||||
deleteType = 'block';
|
||||
removeFromOrderedArray(this.blocks, element);
|
||||
}
|
||||
|
||||
this._removeFromLayer(element, deleteType);
|
||||
|
||||
this._buildCanvasLayers();
|
||||
this.slots.layerUpdated.next({
|
||||
type: 'delete',
|
||||
initiatingElement: element,
|
||||
});
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.slots.layerUpdated.complete();
|
||||
this._disposable.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param reverse - if true, generate the index in reverse order
|
||||
* @returns
|
||||
*/
|
||||
generateIndex(reverse = false): string {
|
||||
if (reverse) {
|
||||
const firstIndex = this.layers[0]?.indexes[0];
|
||||
|
||||
return firstIndex
|
||||
? generateKeyBetween(null, ungroupIndex(firstIndex))
|
||||
: LayerManager.INITIAL_INDEX;
|
||||
} else {
|
||||
const lastIndex = last(this.layers)?.indexes[1];
|
||||
|
||||
return lastIndex
|
||||
? generateKeyBetween(ungroupIndex(lastIndex), null)
|
||||
: LayerManager.INITIAL_INDEX;
|
||||
}
|
||||
}
|
||||
|
||||
getCanvasLayers() {
|
||||
return this.canvasLayers;
|
||||
}
|
||||
|
||||
getReorderedIndex(element: GfxModel, direction: ReorderingDirection): string {
|
||||
const group = (element.group as GfxGroupCompatibleInterface) || null;
|
||||
|
||||
let elements: GfxModel[];
|
||||
|
||||
if (group !== null) {
|
||||
elements = group.childElements;
|
||||
|
||||
elements.sort(compare);
|
||||
} else {
|
||||
elements = this.layers.reduce(
|
||||
(pre: GfxModel[], current) =>
|
||||
pre.concat(current.elements.filter(element => element.group == null)),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
const currentIdx = elements.indexOf(element);
|
||||
|
||||
switch (direction) {
|
||||
case 'forward':
|
||||
case 'front':
|
||||
if (currentIdx === -1 || currentIdx === elements.length - 1)
|
||||
return element.index;
|
||||
|
||||
{
|
||||
const next =
|
||||
direction === 'forward'
|
||||
? elements[currentIdx + 1]
|
||||
: elements[elements.length - 1];
|
||||
const next2 =
|
||||
direction === 'forward' ? elements[currentIdx + 2] : null;
|
||||
|
||||
return generateKeyBetween(
|
||||
next.index,
|
||||
next2?.index
|
||||
? next.index < next2.index
|
||||
? next2.index
|
||||
: null
|
||||
: null
|
||||
);
|
||||
}
|
||||
case 'backward':
|
||||
case 'back':
|
||||
if (currentIdx === -1 || currentIdx === 0) return element.index;
|
||||
|
||||
{
|
||||
const pre =
|
||||
direction === 'backward' ? elements[currentIdx - 1] : elements[0];
|
||||
const pre2 =
|
||||
direction === 'backward' ? elements[currentIdx - 2] : null;
|
||||
|
||||
return generateKeyBetween(
|
||||
!pre2 || pre2?.index >= pre.index ? null : pre2.index,
|
||||
pre.index
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getZIndex(element: GfxModel): number {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const layer = this.layers.find(layer => layer.set.has(element));
|
||||
|
||||
if (!layer) return -1;
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return layer.zIndex + layer.elements.indexOf(element);
|
||||
}
|
||||
|
||||
update(
|
||||
element: GfxModel | GfxLocalElementModel,
|
||||
props?: Record<string, unknown>
|
||||
) {
|
||||
if (this._updateLayer(element, props)) {
|
||||
this._buildCanvasLayers();
|
||||
this.slots.layerUpdated.next({
|
||||
type: 'update',
|
||||
initiatingElement: element,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const store = this._doc;
|
||||
|
||||
this._disposable.add(
|
||||
store.slots.blockUpdated.subscribe(payload => {
|
||||
if (payload.type === 'add') {
|
||||
const block = store.getModelById(payload.id)!;
|
||||
|
||||
if (
|
||||
block instanceof GfxBlockElementModel &&
|
||||
(block.parent instanceof SurfaceBlockModel ||
|
||||
block.parent?.role === 'root') &&
|
||||
this.blocks.indexOf(block) === -1
|
||||
) {
|
||||
this.add(block as GfxBlockElementModel);
|
||||
}
|
||||
}
|
||||
if (payload.type === 'update') {
|
||||
const block = store.getModelById(payload.id)!;
|
||||
|
||||
if (
|
||||
(payload.props.key === 'index' ||
|
||||
payload.props.key === 'childElementIds') &&
|
||||
block instanceof GfxBlockElementModel &&
|
||||
(block.parent instanceof SurfaceBlockModel ||
|
||||
block.parent?.role === 'root')
|
||||
) {
|
||||
this.update(block as GfxBlockElementModel, {
|
||||
[payload.props.key]: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (payload.type === 'delete') {
|
||||
const block = store.getModelById(payload.id);
|
||||
|
||||
if (block instanceof GfxBlockElementModel) {
|
||||
this.delete(block as GfxBlockElementModel);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const watchSurface = (surface: SurfaceBlockModel) => {
|
||||
let lastChildMap = new Map(surface.childMap.peek());
|
||||
this._disposable.add(
|
||||
surface.childMap.subscribe(val => {
|
||||
val.forEach((_, id) => {
|
||||
if (lastChildMap.has(id)) {
|
||||
lastChildMap.delete(id);
|
||||
return;
|
||||
}
|
||||
});
|
||||
lastChildMap.forEach((_, id) => {
|
||||
const block = this._doc.getBlock(id);
|
||||
if (block?.model) {
|
||||
this.delete(block.model as GfxBlockElementModel);
|
||||
}
|
||||
});
|
||||
lastChildMap = new Map(val);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.elementAdded.subscribe(payload =>
|
||||
this.add(surface.getElementById(payload.id)!)
|
||||
)
|
||||
);
|
||||
this._disposable.add(
|
||||
surface.elementUpdated.subscribe(payload => {
|
||||
if (payload.props['index'] || payload.props['childIds']) {
|
||||
this.update(surface.getElementById(payload.id)!, payload.props);
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposable.add(
|
||||
surface.elementRemoved.subscribe(payload => this.delete(payload.model!))
|
||||
);
|
||||
this._disposable.add(
|
||||
surface.localElementAdded.subscribe(elm => {
|
||||
this.add(elm);
|
||||
})
|
||||
);
|
||||
this._disposable.add(
|
||||
surface.localElementUpdated.subscribe(payload => {
|
||||
if (payload.props['index'] || payload.props['groupId']) {
|
||||
this.update(payload.model, payload.props);
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposable.add(
|
||||
surface.localElementDeleted.subscribe(elm => {
|
||||
this.delete(elm);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (this.gfx.surface) {
|
||||
watchSurface(this.gfx.surface);
|
||||
} else {
|
||||
this._disposable.add(
|
||||
this.gfx.surface$.subscribe(surface => {
|
||||
if (surface) {
|
||||
watchSurface(surface);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
167
blocksuite/framework/std/src/gfx/model/base.ts
Normal file
167
blocksuite/framework/std/src/gfx/model/base.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type {
|
||||
Bound,
|
||||
IBound,
|
||||
IVec,
|
||||
PointLocation,
|
||||
SerializedXYWH,
|
||||
XYWH,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
import type { EditorHost } from '../../view/element/lit-host.js';
|
||||
import type { GfxGroupModel, GfxModel } from './model.js';
|
||||
|
||||
/**
|
||||
* The methods that a graphic element should implement.
|
||||
* It is already included in the `GfxCompatibleInterface` interface.
|
||||
*/
|
||||
export interface GfxElementGeometry {
|
||||
containsBound(bound: Bound): boolean;
|
||||
getNearestPoint(point: IVec): IVec;
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null;
|
||||
getRelativePointLocation(point: IVec): PointLocation;
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions,
|
||||
host: EditorHost
|
||||
): boolean;
|
||||
intersectsBound(bound: Bound): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the model that can be rendered in graphics mode should implement this interface.
|
||||
*/
|
||||
export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's bound.
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number];
|
||||
|
||||
readonly group: GfxGroupCompatibleInterface | null;
|
||||
|
||||
readonly groups: GfxGroupCompatibleInterface[];
|
||||
|
||||
readonly deserializedXYWH: XYWH;
|
||||
|
||||
/**
|
||||
* The bound of the element without considering the response extension.
|
||||
*/
|
||||
readonly elementBound: Bound;
|
||||
|
||||
/**
|
||||
* The bound of the element considering the response extension.
|
||||
*/
|
||||
readonly responseBound: Bound;
|
||||
|
||||
/**
|
||||
* Indicates whether the current block is explicitly locked by self.
|
||||
* For checking the lock status of the element, use `isLocked` instead.
|
||||
* For (un)locking the element, use `(un)lock` instead.
|
||||
*/
|
||||
lockedBySelf?: boolean;
|
||||
|
||||
/**
|
||||
* Check if the element is locked. It will check the lock status of the element and its ancestors.
|
||||
*/
|
||||
isLocked(): boolean;
|
||||
isLockedBySelf(): boolean;
|
||||
isLockedByAncestor(): boolean;
|
||||
|
||||
lock(): void;
|
||||
unlock(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol to mark a model as a container.
|
||||
*/
|
||||
export const gfxGroupCompatibleSymbol = Symbol('GfxGroupCompatible');
|
||||
|
||||
/**
|
||||
* Check if the element is a container element.
|
||||
*/
|
||||
export const isGfxGroupCompatibleModel = (
|
||||
elm: unknown
|
||||
): elm is GfxGroupModel => {
|
||||
if (typeof elm !== 'object' || elm === null) return false;
|
||||
return (
|
||||
gfxGroupCompatibleSymbol in elm && elm[gfxGroupCompatibleSymbol] === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GfxGroupCompatibleElement is a model that can contain other models.
|
||||
* It just like a group that in common graphic software.
|
||||
*/
|
||||
export interface GfxGroupCompatibleInterface extends GfxCompatibleInterface {
|
||||
[gfxGroupCompatibleSymbol]: true;
|
||||
|
||||
/**
|
||||
* All child ids of this container.
|
||||
*/
|
||||
childIds: string[];
|
||||
|
||||
/**
|
||||
* All child element models of this container.
|
||||
* Note that the `childElements` may not contains all the children in `childIds`,
|
||||
* because some children may not be loaded.
|
||||
*/
|
||||
childElements: GfxModel[];
|
||||
|
||||
descendantElements: GfxModel[];
|
||||
|
||||
addChild(element: GfxCompatibleInterface): void;
|
||||
removeChild(element: GfxCompatibleInterface): void;
|
||||
hasChild(element: GfxCompatibleInterface): boolean;
|
||||
|
||||
hasDescendant(element: GfxCompatibleInterface): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for the hit testing of a point.
|
||||
*/
|
||||
export interface PointTestOptions {
|
||||
/**
|
||||
* The threshold of the hit test. The unit is pixel.
|
||||
*/
|
||||
hitThreshold?: number;
|
||||
|
||||
/**
|
||||
* If true, the element bound will be used for the hit testing.
|
||||
* By default, the response bound will be used.
|
||||
*/
|
||||
useElementBound?: boolean;
|
||||
|
||||
/**
|
||||
* The padding of the response area for each element when do the hit testing. The unit is pixel.
|
||||
* The first value is the padding for the x-axis, and the second value is the padding for the y-axis.
|
||||
*/
|
||||
responsePadding?: [number, number];
|
||||
|
||||
/**
|
||||
* If true, the transparent area of the element will be ignored during the point inclusion test.
|
||||
* Otherwise, the transparent area will be considered as filled area.
|
||||
*
|
||||
* Default is true.
|
||||
*/
|
||||
ignoreTransparent?: boolean;
|
||||
|
||||
/**
|
||||
* The zoom level of current view when do the hit testing.
|
||||
*/
|
||||
zoom?: number;
|
||||
}
|
||||
303
blocksuite/framework/std/src/gfx/model/gfx-block-model.ts
Normal file
303
blocksuite/framework/std/src/gfx/model/gfx-block-model.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
isLockedByAncestorImpl,
|
||||
isLockedBySelfImpl,
|
||||
isLockedImpl,
|
||||
lockElementImpl,
|
||||
unlockElementImpl,
|
||||
} from '../../utils/tree.js';
|
||||
import type { EditorHost } from '../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from './base.js';
|
||||
import type { GfxGroupModel } from './model.js';
|
||||
import type { SurfaceBlockModel } from './surface/surface-model.js';
|
||||
|
||||
/**
|
||||
* The props that a graphics block model should have.
|
||||
*/
|
||||
export type GfxCompatibleProps = {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
lockedBySelf?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This type include the common props for the graphic block model.
|
||||
* You can use this type with Omit to define the props of a graphic block model.
|
||||
*/
|
||||
export type GfxCommonBlockProps = GfxCompatibleProps & {
|
||||
rotate: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphic block model that can be rendered in the graphics mode.
|
||||
* All the graphic block model should extend this class.
|
||||
* You can use `GfxCompatibleBlockModel` to convert a BlockModel to a subclass that extends it.
|
||||
*/
|
||||
export class GfxBlockElementModel<
|
||||
Props extends GfxCompatibleProps = GfxCompatibleProps,
|
||||
>
|
||||
extends BlockModel<Props>
|
||||
implements GfxCompatibleInterface
|
||||
{
|
||||
private _cacheDeserKey: string | null = null;
|
||||
|
||||
private _cacheDeserXYWH: XYWH | null = null;
|
||||
|
||||
private _externalXYWH: SerializedXYWH | undefined = undefined;
|
||||
|
||||
connectable = true;
|
||||
|
||||
get xywh() {
|
||||
return this.props.xywh;
|
||||
}
|
||||
|
||||
get xywh$() {
|
||||
return this.props.xywh$;
|
||||
}
|
||||
|
||||
set xywh(xywh: SerializedXYWH) {
|
||||
this.props.xywh = xywh;
|
||||
}
|
||||
|
||||
get index() {
|
||||
return this.props.index;
|
||||
}
|
||||
|
||||
get index$() {
|
||||
return this.props.index$;
|
||||
}
|
||||
|
||||
set index(index: string) {
|
||||
this.props.index = index;
|
||||
}
|
||||
|
||||
get lockedBySelf(): boolean | undefined {
|
||||
return this.props.lockedBySelf;
|
||||
}
|
||||
|
||||
get lockedBySelf$() {
|
||||
return this.props.lockedBySelf$;
|
||||
}
|
||||
|
||||
set lockedBySelf(lockedBySelf: boolean | undefined) {
|
||||
this.props.lockedBySelf = lockedBySelf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height].
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number] = [0, 0];
|
||||
|
||||
rotate = 0;
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {
|
||||
this._cacheDeserKey = this.xywh;
|
||||
this._cacheDeserXYWH = deserializeXYWH(this.xywh);
|
||||
}
|
||||
|
||||
return this._cacheDeserXYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return Bound.from(getBoundWithRotation(this));
|
||||
}
|
||||
|
||||
get externalBound(): Bound | null {
|
||||
return this._externalXYWH ? Bound.deserialize(this._externalXYWH) : null;
|
||||
}
|
||||
|
||||
get externalXYWH(): SerializedXYWH | undefined {
|
||||
return this._externalXYWH;
|
||||
}
|
||||
|
||||
set externalXYWH(xywh: SerializedXYWH | undefined) {
|
||||
this._externalXYWH = xywh;
|
||||
}
|
||||
|
||||
get group(): GfxGroupModel | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
return this.surface.getGroup(this.id) ?? null;
|
||||
}
|
||||
|
||||
get groups(): GfxGroupModel[] {
|
||||
if (!this.surface) return [];
|
||||
|
||||
return this.surface.getGroups(this.id);
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface(): SurfaceBlockModel | null {
|
||||
const result = this.doc.getBlocksByFlavour('affine:surface');
|
||||
if (result.length === 0) return null;
|
||||
return result[0].model as SurfaceBlockModel;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const points = getPointsFromBoundWithRotation({
|
||||
x: bound.x,
|
||||
y: bound.y,
|
||||
w: bound.w,
|
||||
h: bound.h,
|
||||
rotate: this.rotate,
|
||||
});
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
|
||||
return linePolygonIntersects(
|
||||
start,
|
||||
end,
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec): IVec {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
return polygonNearestPoint(
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0),
|
||||
point
|
||||
);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec): PointLocation {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints(
|
||||
[point],
|
||||
bound.center,
|
||||
this.rotate ?? 0
|
||||
)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate ?? 0);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y], 0);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked(): boolean {
|
||||
return isLockedImpl(this);
|
||||
}
|
||||
|
||||
isLockedByAncestor(): boolean {
|
||||
return isLockedByAncestorImpl(this);
|
||||
}
|
||||
|
||||
isLockedBySelf(): boolean {
|
||||
return isLockedBySelfImpl(this);
|
||||
}
|
||||
|
||||
lock() {
|
||||
lockElementImpl(this.doc, this);
|
||||
}
|
||||
|
||||
unlock() {
|
||||
unlockElementImpl(this.doc, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BlockModel to a GfxBlockElementModel.
|
||||
* @param BlockModelSuperClass The BlockModel class to be converted.
|
||||
* @returns The returned class is a subclass of the GfxBlockElementModel class and the given BlockModelSuperClass.
|
||||
*/
|
||||
export function GfxCompatibleBlockModel<
|
||||
Props extends GfxCompatibleProps,
|
||||
T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>,
|
||||
>(BlockModelSuperClass: T) {
|
||||
if (BlockModelSuperClass === BlockModel) {
|
||||
return GfxBlockElementModel as unknown as typeof GfxBlockElementModel<Props>;
|
||||
} else {
|
||||
let currentClass = BlockModelSuperClass;
|
||||
|
||||
while (
|
||||
Object.getPrototypeOf(currentClass.prototype) !== BlockModel.prototype &&
|
||||
Object.getPrototypeOf(currentClass.prototype) !== null
|
||||
) {
|
||||
currentClass = Object.getPrototypeOf(currentClass.prototype).constructor;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(currentClass.prototype) === null) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'The SuperClass is not a subclass of BlockModel'
|
||||
);
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(
|
||||
currentClass.prototype,
|
||||
GfxBlockElementModel.prototype
|
||||
);
|
||||
}
|
||||
|
||||
return BlockModelSuperClass as unknown as typeof GfxBlockElementModel<Props>;
|
||||
}
|
||||
12
blocksuite/framework/std/src/gfx/model/model.ts
Normal file
12
blocksuite/framework/std/src/gfx/model/model.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { GfxGroupCompatibleInterface } from './base.js';
|
||||
import type { GfxBlockElementModel } from './gfx-block-model.js';
|
||||
import type {
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from './surface/element-model.js';
|
||||
|
||||
export type GfxModel = GfxBlockElementModel | GfxPrimitiveElementModel;
|
||||
|
||||
export type GfxGroupModel =
|
||||
| (GfxGroupCompatibleInterface & GfxBlockElementModel)
|
||||
| GfxGroupLikeElementModel;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SurfaceBlockModel } from '../surface-model.js';
|
||||
|
||||
/**
|
||||
* Set metadata for a property
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param target The target object to set metadata on, usually the prototype
|
||||
* @param prop The property name
|
||||
* @param val The value to set
|
||||
*/
|
||||
export function setObjectPropMeta(
|
||||
symbol: symbol,
|
||||
target: unknown,
|
||||
prop: string | symbol,
|
||||
val: unknown
|
||||
) {
|
||||
// @ts-expect-error ignore
|
||||
target[symbol] = target[symbol] ?? {};
|
||||
// @ts-expect-error ignore
|
||||
target[symbol][prop] = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a property
|
||||
* @param target The target object to retrieve metadata from, usually the prototype
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param prop The property name, if not provided, returns all metadata for that symbol
|
||||
* @returns
|
||||
*/
|
||||
export function getObjectPropMeta(
|
||||
target: unknown,
|
||||
symbol: symbol,
|
||||
prop?: string | symbol
|
||||
) {
|
||||
if (prop) {
|
||||
// @ts-expect-error ignore
|
||||
return target[symbol]?.[prop] ?? null;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
return target[symbol] ?? {};
|
||||
}
|
||||
|
||||
export function getDecoratorState(surface: SurfaceBlockModel) {
|
||||
return surface['_decoratorState'];
|
||||
}
|
||||
|
||||
export function createDecoratorState() {
|
||||
return {
|
||||
creating: false,
|
||||
deriving: false,
|
||||
skipField: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const convertSymbol = Symbol('convert');
|
||||
|
||||
/**
|
||||
* The convert decorator is used to convert the property value before it's
|
||||
* set to the Y map.
|
||||
*
|
||||
* Note:
|
||||
* 1. This decorator function will not execute in model initialization.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function convert<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: V, instance: T) => unknown
|
||||
) {
|
||||
return function convertDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
setObjectPropMeta(convertSymbol, proto, prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getConvertMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ((propValue: unknown, instance: unknown) => unknown) {
|
||||
return getObjectPropMeta(proto, convertSymbol, prop);
|
||||
}
|
||||
|
||||
export function convertProps(
|
||||
propName: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: unknown
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const convertFn = getConvertMeta(proto, propName as string)!;
|
||||
|
||||
return convertFn ? convertFn(propValue, receiver) : propValue;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import {
|
||||
getDecoratorState,
|
||||
getObjectPropMeta,
|
||||
setObjectPropMeta,
|
||||
} from './common.js';
|
||||
|
||||
const deriveSymbol = Symbol('derive');
|
||||
|
||||
const keys = Object.keys;
|
||||
|
||||
function getDerivedMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
):
|
||||
| null
|
||||
| ((propValue: unknown, instance: unknown) => Record<string, unknown>)[] {
|
||||
return getObjectPropMeta(proto, deriveSymbol, prop);
|
||||
}
|
||||
|
||||
export function getDerivedProps(
|
||||
prop: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const prototype = Object.getPrototypeOf(receiver);
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
|
||||
if (decoratorState.deriving || decoratorState.creating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deriveFns = getDerivedMeta(prototype, prop as string)!;
|
||||
|
||||
return deriveFns
|
||||
? deriveFns.reduce(
|
||||
(derivedProps, fn) => {
|
||||
const props = fn(propValue, receiver);
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
derivedProps[key] = value;
|
||||
});
|
||||
|
||||
return derivedProps;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function updateDerivedProps(
|
||||
derivedProps: Record<string, unknown> | null,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
if (derivedProps) {
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
decoratorState.deriving = true;
|
||||
keys(derivedProps).forEach(key => {
|
||||
// @ts-expect-error ignore
|
||||
receiver[key] = derivedProps[key];
|
||||
});
|
||||
decoratorState.deriving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The derive decorator is used to derive other properties' update when the
|
||||
* decorated property is updated through assignment in the local.
|
||||
*
|
||||
* Note:
|
||||
* 1. The first argument of the function is the new value of the decorated property
|
||||
* before the `convert` decorator is called.
|
||||
* 2. The decorator function will execute after the decorated property has been updated.
|
||||
* 3. The decorator function will not execute during model creation.
|
||||
* 4. The decorator function will not execute if the decorated property is updated through
|
||||
* the Y map. That is to say, if other peers update the property will not trigger this decorator
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function derive<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: any, instance: T) => Record<string, unknown>
|
||||
) {
|
||||
return function deriveDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
const derived = getDerivedMeta(proto, prop);
|
||||
|
||||
if (Array.isArray(derived)) {
|
||||
derived.push(fn as (typeof derived)[0]);
|
||||
} else {
|
||||
setObjectPropMeta(deriveSymbol, proto, prop as string, [fn]);
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
import { startObserve } from './observer.js';
|
||||
|
||||
const yPropsSetSymbol = Symbol('yProps');
|
||||
|
||||
export function getFieldPropsSet(target: unknown): Set<string | symbol> {
|
||||
const proto = Object.getPrototypeOf(target);
|
||||
if (!Object.hasOwn(proto, yPropsSetSymbol)) {
|
||||
proto[yPropsSetSymbol] = new Set();
|
||||
}
|
||||
|
||||
return proto[yPropsSetSymbol] as Set<string | symbol>;
|
||||
}
|
||||
|
||||
export function field<V, T extends GfxPrimitiveElementModel>(fallback?: V) {
|
||||
return function yDecorator(
|
||||
_: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const yProps = getFieldPropsSet(this);
|
||||
|
||||
yProps.add(prop);
|
||||
|
||||
if (
|
||||
getDecoratorState(
|
||||
this.surface ?? Object.getPrototypeOf(this).constructor
|
||||
)?.skipField
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.yMap) {
|
||||
if (this.yMap.doc) {
|
||||
this.surface.doc.transact(() => {
|
||||
this.yMap.set(prop as string, v);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, v);
|
||||
this._preserved.set(prop as string, v);
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: GfxPrimitiveElementModel) {
|
||||
return (
|
||||
(this.yMap.doc ? this.yMap.get(prop as string) : null) ??
|
||||
this._preserved.get(prop as string) ??
|
||||
fallback
|
||||
);
|
||||
},
|
||||
set(this: T, originalVal: V) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
|
||||
if (getDecoratorState(this.surface)?.skipField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalVal, this);
|
||||
const val = isCreating
|
||||
? originalVal
|
||||
: convertProps(prop, originalVal, this);
|
||||
|
||||
if (this.yMap.doc) {
|
||||
this.surface.doc.transact(() => {
|
||||
this.yMap.set(prop as string, val);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, val);
|
||||
this._preserved.set(prop as string, val);
|
||||
}
|
||||
|
||||
startObserve(prop as string, this);
|
||||
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { convert, convertProps } from './convert.js';
|
||||
export { derive, getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
export { field, getFieldPropsSet } from './field.js';
|
||||
export { local } from './local.js';
|
||||
export { initializeObservers, observe } from './observer.js';
|
||||
export { initializeWatchers, watch } from './watch.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
|
||||
/**
|
||||
* A decorator to mark the property as a local property.
|
||||
*
|
||||
* The local property act like it is a field property, but it's not synced to the Y map.
|
||||
* Updating local property will also trigger the `elementUpdated` slot of the surface model
|
||||
*/
|
||||
export function local<V, T extends GfxPrimitiveElementModel>() {
|
||||
return function localDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
this._local.set(prop, v);
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, originalValue: unknown) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
const oldValue = this._local.get(prop);
|
||||
// When state is creating, the value is considered as default value
|
||||
// hence there's no need to convert it
|
||||
const newVal = isCreating
|
||||
? originalValue
|
||||
: convertProps(prop, originalValue, this);
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalValue, this);
|
||||
|
||||
this._local.set(prop, newVal);
|
||||
|
||||
// During creating, no need to invoke an update event and derive another update
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
[prop]: newVal,
|
||||
},
|
||||
oldValues: {
|
||||
[prop]: oldValue,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const observeSymbol = Symbol('observe');
|
||||
const observerDisposableSymbol = Symbol('observerDisposable');
|
||||
|
||||
type ObserveFn<
|
||||
E extends Y.YEvent<any> = Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel,
|
||||
> = (
|
||||
/**
|
||||
* The event object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
event: E | null,
|
||||
instance: T,
|
||||
/**
|
||||
* The transaction object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
transaction: Y.Transaction | null
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* A decorator to observe the y type property.
|
||||
* You can think of it is just a decorator version of 'observe' method of Y.Array and Y.Map.
|
||||
*
|
||||
* The observer function start to observe the property when the model is mounted. And it will
|
||||
* re-observe the property automatically when the value is altered.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function observe<
|
||||
V,
|
||||
E extends Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel,
|
||||
>(fn: ObserveFn<E, T>) {
|
||||
return function observeDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
setObjectPropMeta(observeSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getObserveMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ObserveFn {
|
||||
return getObjectPropMeta(proto, observeSymbol, prop);
|
||||
}
|
||||
|
||||
export function startObserve(
|
||||
prop: string | symbol,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const observeFn = getObserveMeta(proto, prop as string)!;
|
||||
// @ts-expect-error ignore
|
||||
const observerDisposable = receiver[observerDisposableSymbol] ?? {};
|
||||
|
||||
// @ts-expect-error ignore
|
||||
receiver[observerDisposableSymbol] = observerDisposable;
|
||||
|
||||
if (observerDisposable[prop]) {
|
||||
observerDisposable[prop]();
|
||||
delete observerDisposable[prop];
|
||||
}
|
||||
|
||||
if (!observeFn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = receiver[prop as keyof GfxPrimitiveElementModel] as
|
||||
| Y.Map<unknown>
|
||||
| Y.Array<unknown>
|
||||
| null;
|
||||
|
||||
observeFn(null, receiver, null);
|
||||
|
||||
const fn = (event: Y.YEvent<any>, transaction: Y.Transaction) => {
|
||||
observeFn(event, receiver, transaction);
|
||||
};
|
||||
|
||||
if (value && 'observe' in value) {
|
||||
value.observe(fn);
|
||||
|
||||
observerDisposable[prop] = () => {
|
||||
value.unobserve(fn);
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`Failed to observe "${prop.toString()}" of ${
|
||||
receiver.type
|
||||
} element, make sure it's a Y type.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeObservers(
|
||||
proto: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const observers = getObjectPropMeta(proto, observeSymbol);
|
||||
|
||||
Object.keys(observers).forEach(prop => {
|
||||
startObserve(prop, receiver);
|
||||
});
|
||||
|
||||
receiver['_disposable'].add(() => {
|
||||
// @ts-expect-error ignore
|
||||
Object.values(receiver[observerDisposableSymbol] ?? {}).forEach(dispose =>
|
||||
(dispose as () => void)()
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
type WatchFn<T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel> = (
|
||||
oldValue: unknown,
|
||||
instance: T,
|
||||
local: boolean
|
||||
) => void;
|
||||
|
||||
const watchSymbol = Symbol('watch');
|
||||
|
||||
/**
|
||||
* The watch decorator is used to watch the property change of the element.
|
||||
* You can thinks of it as a decorator version of `elementUpdated` slot of the surface model.
|
||||
*/
|
||||
export function watch<V, T extends GfxPrimitiveElementModel>(fn: WatchFn<T>) {
|
||||
return function watchDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
setObjectPropMeta(watchSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getWatchMeta(proto: unknown, prop: string | symbol): null | WatchFn {
|
||||
return getObjectPropMeta(proto, watchSymbol, prop);
|
||||
}
|
||||
|
||||
function startWatch(prop: string | symbol, receiver: GfxPrimitiveElementModel) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const watchFn = getWatchMeta(proto, prop as string)!;
|
||||
|
||||
if (!watchFn) return;
|
||||
|
||||
receiver['_disposable'].add(
|
||||
receiver.surface.elementUpdated.subscribe(payload => {
|
||||
if (payload.id === receiver.id && prop in payload.props) {
|
||||
watchFn(payload.oldValues[prop as string], receiver, payload.local);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeWatchers(
|
||||
prototype: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const watchers = getObjectPropMeta(prototype, watchSymbol);
|
||||
|
||||
Object.keys(watchers).forEach(prop => {
|
||||
startWatch(prop, receiver);
|
||||
});
|
||||
}
|
||||
600
blocksuite/framework/std/src/gfx/model/surface/element-model.ts
Normal file
600
blocksuite/framework/std/src/gfx/model/surface/element-model.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
type IVec,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
randomSeed,
|
||||
rotatePoints,
|
||||
type SerializedXYWH,
|
||||
type XYWH,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { createMutex } from 'lib0/mutex';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
descendantElementsImpl,
|
||||
hasDescendantElementImpl,
|
||||
isLockedByAncestorImpl,
|
||||
isLockedBySelfImpl,
|
||||
isLockedImpl,
|
||||
lockElementImpl,
|
||||
unlockElementImpl,
|
||||
} from '../../../utils/tree.js';
|
||||
import type { EditorHost } from '../../../view/index.js';
|
||||
import type {
|
||||
GfxCompatibleInterface,
|
||||
GfxGroupCompatibleInterface,
|
||||
PointTestOptions,
|
||||
} from '../base.js';
|
||||
import { gfxGroupCompatibleSymbol } from '../base.js';
|
||||
import type { GfxBlockElementModel } from '../gfx-block-model.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import {
|
||||
convertProps,
|
||||
field,
|
||||
getDerivedProps,
|
||||
getFieldPropsSet,
|
||||
local,
|
||||
updateDerivedProps,
|
||||
watch,
|
||||
} from './decorators/index.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export type BaseElementProps = {
|
||||
index: string;
|
||||
seed: number;
|
||||
lockedBySelf?: boolean;
|
||||
};
|
||||
|
||||
export type SerializedElement = Record<string, unknown> & {
|
||||
type: string;
|
||||
xywh: SerializedXYWH;
|
||||
id: string;
|
||||
index: string;
|
||||
lockedBySelf?: boolean;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
export abstract class GfxPrimitiveElementModel<
|
||||
Props extends BaseElementProps = BaseElementProps,
|
||||
> implements GfxCompatibleInterface
|
||||
{
|
||||
private _lastXYWH!: SerializedXYWH;
|
||||
|
||||
protected _disposable = new DisposableGroup();
|
||||
|
||||
protected _id: string;
|
||||
|
||||
protected _local = new Map<string | symbol, unknown>();
|
||||
|
||||
protected _onChange: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Used to store a copy of data in the yMap.
|
||||
*/
|
||||
protected _preserved = new Map<string, unknown>();
|
||||
|
||||
protected _stashed: Map<keyof Props | string, unknown>;
|
||||
|
||||
propsUpdated = new Subject<{ key: string }>();
|
||||
|
||||
abstract rotate: number;
|
||||
|
||||
surface!: SurfaceBlockModel;
|
||||
|
||||
abstract xywh: SerializedXYWH;
|
||||
|
||||
yMap: Y.Map<unknown>;
|
||||
|
||||
get connectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (!this._lastXYWH || this.xywh !== this._lastXYWH) {
|
||||
const xywh = this.xywh;
|
||||
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
|
||||
this._lastXYWH = xywh;
|
||||
}
|
||||
|
||||
return (this._local.get('deserializedXYWH') as XYWH) ?? [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bound of the element after rotation.
|
||||
* The bound without rotation should be created by `Bound.deserialize(this.xywh)`.
|
||||
*/
|
||||
get elementBound() {
|
||||
if (this.rotate) {
|
||||
return Bound.from(getBoundWithRotation(this));
|
||||
}
|
||||
|
||||
return Bound.deserialize(this.xywh);
|
||||
}
|
||||
|
||||
get externalBound(): Bound | null {
|
||||
if (!this._local.has('externalBound')) {
|
||||
const bound = this.externalXYWH
|
||||
? Bound.deserialize(this.externalXYWH)
|
||||
: null;
|
||||
|
||||
this._local.set('externalBound', bound);
|
||||
}
|
||||
|
||||
return this._local.get('externalBound') as Bound | null;
|
||||
}
|
||||
|
||||
get group(): GfxGroupModel | null {
|
||||
return this.surface.getGroup(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the ancestor elements in order from the most recent to the earliest.
|
||||
*/
|
||||
get groups(): GfxGroupModel[] {
|
||||
return this.surface.getGroups(this.id);
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.surface.hasElementById(this.id);
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
abstract get type(): string;
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
id: string;
|
||||
yMap: Y.Map<unknown>;
|
||||
model: SurfaceBlockModel;
|
||||
stashedStore: Map<unknown, unknown>;
|
||||
onChange: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
const { id, yMap, model, stashedStore, onChange } = options;
|
||||
|
||||
this._id = id;
|
||||
this.yMap = yMap;
|
||||
this.surface = model;
|
||||
this._stashed = stashedStore as Map<keyof Props, unknown>;
|
||||
this._onChange = onChange;
|
||||
|
||||
this.index = 'a0';
|
||||
this.seed = randomSeed();
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return getPointsFromBoundWithRotation(this).some(point =>
|
||||
bounds.containsPoint(point)
|
||||
);
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return polygonNearestPoint(points, point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y]);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked(): boolean {
|
||||
return isLockedImpl(this);
|
||||
}
|
||||
|
||||
isLockedByAncestor(): boolean {
|
||||
return isLockedByAncestorImpl(this);
|
||||
}
|
||||
|
||||
isLockedBySelf(): boolean {
|
||||
return isLockedBySelfImpl(this);
|
||||
}
|
||||
|
||||
lock() {
|
||||
lockElementImpl(this.surface.doc, this);
|
||||
}
|
||||
|
||||
onCreated() {}
|
||||
|
||||
onDestroyed() {
|
||||
this._disposable.dispose();
|
||||
this.propsUpdated.complete();
|
||||
}
|
||||
|
||||
pop(prop: keyof Props | string) {
|
||||
if (!this._stashed.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this._stashed.get(prop);
|
||||
this._stashed.delete(prop);
|
||||
// @ts-expect-error ignore
|
||||
delete this[prop];
|
||||
|
||||
if (getFieldPropsSet(this).has(prop as string)) {
|
||||
if (!isEqual(value, this.yMap.get(prop as string))) {
|
||||
this.surface.doc.transact(() => {
|
||||
this.yMap.set(prop as string, value);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('pop a prop that is not field or local:', prop);
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const result = this.yMap.toJSON();
|
||||
result.xywh = this.xywh;
|
||||
return result as SerializedElement;
|
||||
}
|
||||
|
||||
stash(prop: keyof Props | string) {
|
||||
if (this._stashed.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getFieldPropsSet(this).has(prop as string)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const curVal = this[prop as unknown as keyof GfxPrimitiveElementModel];
|
||||
|
||||
this._stashed.set(prop, curVal);
|
||||
|
||||
Object.defineProperty(this, prop, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => this._stashed.get(prop),
|
||||
set: (original: unknown) => {
|
||||
const value = convertProps(prop as string, original, this);
|
||||
const oldValue = this._stashed.get(prop);
|
||||
const derivedProps = getDerivedProps(
|
||||
prop as string,
|
||||
original,
|
||||
this as unknown as GfxPrimitiveElementModel
|
||||
);
|
||||
|
||||
this._stashed.set(prop, value);
|
||||
this._onChange({
|
||||
props: {
|
||||
[prop]: value,
|
||||
},
|
||||
oldValues: {
|
||||
[prop]: oldValue,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
|
||||
updateDerivedProps(
|
||||
derivedProps,
|
||||
this as unknown as GfxPrimitiveElementModel
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
unlock() {
|
||||
unlockElementImpl(this.surface.doc, this);
|
||||
}
|
||||
|
||||
@local()
|
||||
accessor display: boolean = true;
|
||||
|
||||
/**
|
||||
* In some cases, you need to draw something related to the element, but it does not belong to the element itself.
|
||||
* And it is also interactive, you can select element by clicking on it. E.g. the title of the group element.
|
||||
* In this case, we need to store this kind of external xywh in order to do hit test. This property should not be synced to the doc.
|
||||
* This property should be updated every time it gets rendered.
|
||||
*/
|
||||
@watch((_, instance) => {
|
||||
instance['_local'].delete('externalBound');
|
||||
})
|
||||
@local()
|
||||
accessor externalXYWH: SerializedXYWH | undefined = undefined;
|
||||
|
||||
@field(false)
|
||||
accessor hidden: boolean = false;
|
||||
|
||||
@field()
|
||||
accessor index!: string;
|
||||
|
||||
@field()
|
||||
accessor lockedBySelf: boolean | undefined = false;
|
||||
|
||||
@local()
|
||||
accessor opacity: number = 1;
|
||||
|
||||
@local()
|
||||
accessor responseExtension: [number, number] = [0, 0];
|
||||
|
||||
@field()
|
||||
accessor seed!: number;
|
||||
}
|
||||
|
||||
export abstract class GfxGroupLikeElementModel<
|
||||
Props extends BaseElementProps = BaseElementProps,
|
||||
>
|
||||
extends GfxPrimitiveElementModel<Props>
|
||||
implements GfxGroupCompatibleInterface
|
||||
{
|
||||
private _childIds: string[] = [];
|
||||
|
||||
private readonly _mutex = createMutex();
|
||||
|
||||
abstract children: Y.Map<any>;
|
||||
|
||||
[gfxGroupCompatibleSymbol] = true as const;
|
||||
|
||||
get childElements() {
|
||||
const elements: GfxModel[] = [];
|
||||
|
||||
for (const key of this.childIds) {
|
||||
const element =
|
||||
this.surface.getElementById(key) ||
|
||||
(this.surface.doc.getModelById(key) as GfxBlockElementModel);
|
||||
|
||||
element && elements.push(element);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the children. Its role is to provide a unique way to access the children.
|
||||
* You should update this field through `setChildIds` when the children are added or removed.
|
||||
*/
|
||||
get childIds() {
|
||||
return this._childIds;
|
||||
}
|
||||
|
||||
get descendantElements(): GfxModel[] {
|
||||
return descendantElementsImpl(this);
|
||||
}
|
||||
|
||||
get xywh() {
|
||||
this._mutex(() => {
|
||||
const curXYWH =
|
||||
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
||||
const newXYWH = this._getXYWH().serialize();
|
||||
|
||||
if (curXYWH !== newXYWH || !this._local.has('xywh')) {
|
||||
this._local.set('xywh', newXYWH);
|
||||
|
||||
if (curXYWH !== newXYWH) {
|
||||
this._onChange({
|
||||
props: {
|
||||
xywh: newXYWH,
|
||||
},
|
||||
oldValues: {
|
||||
xywh: curXYWH,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
||||
}
|
||||
|
||||
set xywh(_) {}
|
||||
|
||||
protected _getXYWH(): Bound {
|
||||
let bound: Bound | undefined;
|
||||
|
||||
this.childElements.forEach(child => {
|
||||
if (child instanceof GfxPrimitiveElementModel && child.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
|
||||
});
|
||||
|
||||
if (bound) {
|
||||
this._local.set('xywh', bound.serialize());
|
||||
} else {
|
||||
this._local.delete('xywh');
|
||||
}
|
||||
|
||||
return bound ?? new Bound(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
abstract addChild(element: GfxModel): void;
|
||||
|
||||
/**
|
||||
* The actual field that stores the children of the group.
|
||||
* It should be a ymap decorated with `@field`.
|
||||
*/
|
||||
hasChild(element: GfxCompatibleInterface) {
|
||||
return this.childElements.includes(element as GfxModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the group has the given descendant.
|
||||
*/
|
||||
hasDescendant(element: GfxCompatibleInterface): boolean {
|
||||
return hasDescendantElementImpl(this, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the child from the group
|
||||
*/
|
||||
abstract removeChild(element: GfxCompatibleInterface): void;
|
||||
|
||||
/**
|
||||
* Set the new value of the childIds
|
||||
* @param value the new value of the childIds
|
||||
* @param fromLocal if true, the change is happened in the local
|
||||
*/
|
||||
setChildIds(value: string[], fromLocal: boolean) {
|
||||
const oldChildIds = this.childIds;
|
||||
this._childIds = value;
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
childIds: value,
|
||||
},
|
||||
oldValues: {
|
||||
childIds: oldChildIds,
|
||||
},
|
||||
local: fromLocal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncElementFromY(
|
||||
model: GfxPrimitiveElementModel,
|
||||
callback: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void
|
||||
) {
|
||||
const disposables: Record<string, () => void> = {};
|
||||
const observer = (
|
||||
event: Y.YMapEvent<unknown>,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
const props: Record<string, unknown> = {};
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
|
||||
event.keysChanged.forEach(key => {
|
||||
const type = event.changes.keys.get(key);
|
||||
const oldValue = event.changes.keys.get(key)?.oldValue;
|
||||
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.action === 'update' || type.action === 'add') {
|
||||
const value = model.yMap.get(key);
|
||||
|
||||
if (value instanceof Y.Text) {
|
||||
disposables[key]?.();
|
||||
disposables[key] = watchText(key, value, callback);
|
||||
}
|
||||
|
||||
model['_preserved'].set(key, value);
|
||||
props[key] = value;
|
||||
oldValues[key] = oldValue;
|
||||
} else {
|
||||
model['_preserved'].delete(key);
|
||||
oldValues[key] = oldValue;
|
||||
}
|
||||
});
|
||||
|
||||
callback({
|
||||
props,
|
||||
oldValues,
|
||||
local: transaction.local,
|
||||
});
|
||||
};
|
||||
|
||||
Array.from(model.yMap.entries()).forEach(([key, value]) => {
|
||||
if (value instanceof Y.Text) {
|
||||
disposables[key] = watchText(key, value, callback);
|
||||
}
|
||||
|
||||
model['_preserved'].set(key, value);
|
||||
});
|
||||
|
||||
model.yMap.observe(observer);
|
||||
disposables['ymap'] = () => {
|
||||
model.yMap.unobserve(observer);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Object.values(disposables).forEach(fn => fn());
|
||||
};
|
||||
}
|
||||
|
||||
function watchText(
|
||||
key: string,
|
||||
value: Y.Text,
|
||||
callback: (payload: {
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void
|
||||
) {
|
||||
const fn = (_: Y.YTextEvent, transaction: Y.Transaction) => {
|
||||
callback({
|
||||
props: {
|
||||
[key]: value,
|
||||
},
|
||||
oldValues: {},
|
||||
local: transaction.local,
|
||||
});
|
||||
};
|
||||
|
||||
value.observe(fn);
|
||||
|
||||
return () => {
|
||||
value.unobserve(fn);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { mutex } from 'lib0';
|
||||
|
||||
import type { EditorHost } from '../../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from '../base.js';
|
||||
import type { GfxGroupModel } from '../model.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export function prop<V, T extends GfxLocalElementModel>() {
|
||||
return function propDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, val: unknown) {
|
||||
this._props.add(prop);
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, val: V) {
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
|
||||
private readonly _mutex: mutex.mutex = mutex.createMutex();
|
||||
|
||||
protected _local = new Map<string | symbol, unknown>();
|
||||
|
||||
/**
|
||||
* Used to store all the name of the properties that have been decorated
|
||||
* with the `@prop`
|
||||
*/
|
||||
protected _props = new Set<string | symbol>();
|
||||
|
||||
protected _surface: SurfaceBlockModel;
|
||||
|
||||
/**
|
||||
* used to store the properties' cache key
|
||||
* when the properties required heavy computation
|
||||
*/
|
||||
cache = new Map<string | symbol, unknown>();
|
||||
|
||||
id: string = '';
|
||||
|
||||
abstract readonly type: string;
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (!this._local.has('deserializedXYWH')) {
|
||||
const xywh = this.xywh;
|
||||
const deserialized = deserializeXYWH(xywh);
|
||||
|
||||
this._local.set('deserializedXYWH', deserialized);
|
||||
}
|
||||
|
||||
return this._local.get('deserializedXYWH') as XYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return new Bound(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
|
||||
get group() {
|
||||
return (
|
||||
this.groupId ? this._surface.getElementById(this.groupId) : null
|
||||
) as GfxGroupModel | null;
|
||||
}
|
||||
|
||||
get groups() {
|
||||
if (this.group) {
|
||||
const groups = this._surface.getGroups(this.group.id);
|
||||
groups.unshift(this.group);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
constructor(surfaceModel: SurfaceBlockModel) {
|
||||
this._surface = surfaceModel;
|
||||
|
||||
const p = new Proxy(this, {
|
||||
set: (target, prop, value) => {
|
||||
if (prop === 'xywh') {
|
||||
this._local.delete('deserializedXYWH');
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
const oldValue = target[prop as string];
|
||||
|
||||
if (oldValue === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-expect-error ignore
|
||||
target[prop as string] = value;
|
||||
|
||||
if (!this._props.has(prop)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (surfaceModel.localElementModels.has(p)) {
|
||||
this._mutex(() => {
|
||||
surfaceModel.localElementUpdated.next({
|
||||
model: p,
|
||||
props: {
|
||||
[prop as string]: value,
|
||||
},
|
||||
oldValues: {
|
||||
[prop as string]: oldValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line no-constructor-return
|
||||
return p;
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return getPointsFromBoundWithRotation(this).some(point =>
|
||||
bounds.containsPoint(point)
|
||||
);
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return polygonNearestPoint(points, point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
opt: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
|
||||
return bound.isPointInBound([x, y]);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
isLocked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedByAncestor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedBySelf() {
|
||||
return false;
|
||||
}
|
||||
|
||||
lock() {
|
||||
return;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
return;
|
||||
}
|
||||
|
||||
@prop()
|
||||
accessor groupId: string = '';
|
||||
|
||||
@prop()
|
||||
accessor hidden: boolean = false;
|
||||
|
||||
@prop()
|
||||
accessor index: string = 'a0';
|
||||
|
||||
@prop()
|
||||
accessor opacity: number = 1;
|
||||
|
||||
@prop()
|
||||
accessor responseExtension: [number, number] = [0, 0];
|
||||
|
||||
@prop()
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@prop()
|
||||
accessor seed: number = Math.random();
|
||||
|
||||
@prop()
|
||||
accessor xywh: SerializedXYWH = '[0,0,0,0]';
|
||||
}
|
||||
692
blocksuite/framework/std/src/gfx/model/surface/surface-model.ts
Normal file
692
blocksuite/framework/std/src/gfx/model/surface/surface-model.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { assertType, type Constructor } from '@blocksuite/global/utils';
|
||||
import type { Boxed } from '@blocksuite/store';
|
||||
import { BlockModel, nanoid } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '../base.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import { createDecoratorState } from './decorators/common.js';
|
||||
import { initializeObservers, initializeWatchers } from './decorators/index.js';
|
||||
import {
|
||||
GfxGroupLikeElementModel,
|
||||
type GfxPrimitiveElementModel,
|
||||
syncElementFromY,
|
||||
} from './element-model.js';
|
||||
import type { GfxLocalElementModel } from './local-element-model.js';
|
||||
|
||||
/**
|
||||
* Used for text field
|
||||
*/
|
||||
export const SURFACE_TEXT_UNIQ_IDENTIFIER = 'affine:surface:text';
|
||||
/**
|
||||
* Used for field that use Y.Map. E.g. group children field
|
||||
*/
|
||||
export const SURFACE_YMAP_UNIQ_IDENTIFIER = 'affine:surface:ymap';
|
||||
|
||||
export type SurfaceBlockProps = {
|
||||
elements: Boxed<Y.Map<Y.Map<unknown>>>;
|
||||
};
|
||||
|
||||
export interface ElementUpdatedData {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export type MiddlewareCtx = {
|
||||
type: 'beforeAdd';
|
||||
payload: {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
|
||||
|
||||
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
protected _decoratorState = createDecoratorState();
|
||||
|
||||
protected _elementCtorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
> = Object.create(null);
|
||||
|
||||
protected _elementModels = new Map<
|
||||
string,
|
||||
{
|
||||
mount: () => void;
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}
|
||||
>();
|
||||
|
||||
protected _elementTypeMap = new Map<string, GfxPrimitiveElementModel[]>();
|
||||
|
||||
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
||||
|
||||
protected _middlewares: SurfaceMiddleware[] = [];
|
||||
|
||||
protected _surfaceBlockModel = true;
|
||||
|
||||
protected localElements = new Set<GfxLocalElementModel>();
|
||||
|
||||
elementAdded = new Subject<{ id: string; local: boolean }>();
|
||||
|
||||
elementRemoved = new Subject<{
|
||||
id: string;
|
||||
type: string;
|
||||
model: GfxPrimitiveElementModel;
|
||||
local: boolean;
|
||||
}>();
|
||||
|
||||
elementUpdated = new Subject<ElementUpdatedData>();
|
||||
|
||||
localElementAdded = new Subject<GfxLocalElementModel>();
|
||||
|
||||
localElementDeleted = new Subject<GfxLocalElementModel>();
|
||||
|
||||
localElementUpdated = new Subject<{
|
||||
model: GfxLocalElementModel;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
private readonly _isEmpty$ = signal(false);
|
||||
|
||||
get elementModels() {
|
||||
const models: GfxPrimitiveElementModel[] = [];
|
||||
this._elementModels.forEach(model => models.push(model.model));
|
||||
return models;
|
||||
}
|
||||
|
||||
get elements() {
|
||||
return this.props.elements;
|
||||
}
|
||||
|
||||
get localElementModels() {
|
||||
return this.localElements;
|
||||
}
|
||||
|
||||
get registeredElementTypes() {
|
||||
return Object.keys(this._elementCtorMap);
|
||||
}
|
||||
|
||||
override isEmpty(): boolean {
|
||||
return this._isEmpty$.value;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = this.created.subscribe(() => {
|
||||
this._init();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private _createElementFromProps(
|
||||
props: Record<string, unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
}
|
||||
) {
|
||||
const { type, id, ...rest } = props;
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Cannot find id in props');
|
||||
}
|
||||
|
||||
const yMap = new Y.Map();
|
||||
const elementModel = this._createElementFromYMap(
|
||||
type as string,
|
||||
id as string,
|
||||
yMap,
|
||||
{
|
||||
...options,
|
||||
newCreate: true,
|
||||
}
|
||||
);
|
||||
|
||||
props = this._propsToY(type as string, props);
|
||||
|
||||
yMap.set('type', type);
|
||||
yMap.set('id', id);
|
||||
|
||||
Object.keys(rest).forEach(key => {
|
||||
if (props[key] !== undefined) {
|
||||
// @ts-expect-error ignore
|
||||
elementModel.model[key] = props[key];
|
||||
}
|
||||
});
|
||||
|
||||
return elementModel;
|
||||
}
|
||||
|
||||
private _createElementFromYMap(
|
||||
type: string,
|
||||
id: string,
|
||||
yMap: Y.Map<unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
skipFieldInit?: boolean;
|
||||
newCreate?: boolean;
|
||||
}
|
||||
) {
|
||||
const stashed = new Map<string | symbol, unknown>();
|
||||
const Ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!Ctor) {
|
||||
throw new Error(`Invalid element type: ${yMap.get('type')}`);
|
||||
}
|
||||
const state = this._decoratorState;
|
||||
|
||||
state.creating = true;
|
||||
state.skipField = options.skipFieldInit ?? false;
|
||||
|
||||
let mounted = false;
|
||||
// @ts-expect-error ignore
|
||||
Ctor['_decoratorState'] = state;
|
||||
|
||||
const elementModel = new Ctor({
|
||||
id,
|
||||
yMap,
|
||||
model: this,
|
||||
stashedStore: stashed,
|
||||
onChange: payload => mounted && options.onChange({ id, ...payload }),
|
||||
}) as GfxPrimitiveElementModel;
|
||||
|
||||
// @ts-expect-error ignore
|
||||
delete Ctor['_decoratorState'];
|
||||
state.creating = false;
|
||||
state.skipField = false;
|
||||
|
||||
const unmount = () => {
|
||||
mounted = false;
|
||||
elementModel.onDestroyed();
|
||||
};
|
||||
|
||||
const mount = () => {
|
||||
initializeObservers(Ctor.prototype, elementModel);
|
||||
initializeWatchers(Ctor.prototype, elementModel);
|
||||
elementModel['_disposable'].add(
|
||||
syncElementFromY(elementModel, payload => {
|
||||
mounted &&
|
||||
options.onChange({
|
||||
id,
|
||||
...payload,
|
||||
});
|
||||
})
|
||||
);
|
||||
mounted = true;
|
||||
elementModel.onCreated();
|
||||
};
|
||||
|
||||
return {
|
||||
model: elementModel,
|
||||
mount,
|
||||
unmount,
|
||||
};
|
||||
}
|
||||
|
||||
private _initElementModels() {
|
||||
const elementsYMap = this.elements.getValue()!;
|
||||
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
|
||||
if (sameTypeElements.indexOf(model) === -1) {
|
||||
sameTypeElements.push(model);
|
||||
}
|
||||
|
||||
this._elementTypeMap.set(type, sameTypeElements);
|
||||
|
||||
if (isGfxGroupCompatibleModel(model)) {
|
||||
this._groupLikeModels.set(model.id, model);
|
||||
}
|
||||
};
|
||||
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
const index = sameTypeElements.indexOf(model);
|
||||
|
||||
if (index !== -1) {
|
||||
sameTypeElements.splice(index, 1);
|
||||
}
|
||||
|
||||
if (this._groupLikeModels.has(model.id)) {
|
||||
this._groupLikeModels.delete(model.id);
|
||||
}
|
||||
};
|
||||
const onElementsMapChange = (
|
||||
event: Y.YMapEvent<Y.Map<unknown>>,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
const { changes, keysChanged } = event;
|
||||
const addedElements: {
|
||||
mount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
const deletedElements: {
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
|
||||
keysChanged.forEach(id => {
|
||||
const change = changes.keys.get(id);
|
||||
const element = this.elements.getValue()!.get(id);
|
||||
|
||||
switch (change?.action) {
|
||||
case 'add':
|
||||
if (element) {
|
||||
const hasModel = this._elementModels.has(id);
|
||||
const model = hasModel
|
||||
? this._elementModels.get(id)!
|
||||
: this._createElementFromYMap(
|
||||
element.get('type') as string,
|
||||
element.get('id') as string,
|
||||
element,
|
||||
{
|
||||
onChange: payload => {
|
||||
this.elementUpdated.next(payload);
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
model.model.propsUpdated.next({ key });
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
!hasModel && this._elementModels.set(id, model);
|
||||
addToType(model.model.type, model.model);
|
||||
addedElements.push(model);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (this._elementModels.has(id)) {
|
||||
const { model, unmount } = this._elementModels.get(id)!;
|
||||
removeFromType(model.type, model);
|
||||
this._elementModels.delete(id);
|
||||
deletedElements.push({ model, unmount });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
addedElements.forEach(({ mount, model }) => {
|
||||
mount();
|
||||
this.elementAdded.next({ id: model.id, local: transaction.local });
|
||||
});
|
||||
deletedElements.forEach(({ unmount, model }) => {
|
||||
unmount();
|
||||
this.elementRemoved.next({
|
||||
id: model.id,
|
||||
type: model.type,
|
||||
model,
|
||||
local: transaction.local,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
elementsYMap.forEach((val, key) => {
|
||||
const model = this._createElementFromYMap(
|
||||
val.get('type') as string,
|
||||
val.get('id') as string,
|
||||
val,
|
||||
{
|
||||
onChange: payload => {
|
||||
this.elementUpdated.next(payload),
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
model.model.propsUpdated.next({ key });
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
this._elementModels.set(key, model);
|
||||
});
|
||||
|
||||
this._elementModels.forEach(({ mount, model }) => {
|
||||
addToType(model.type, model);
|
||||
mount();
|
||||
});
|
||||
|
||||
Object.values(this.doc.blocks.peek()).forEach(block => {
|
||||
if (isGfxGroupCompatibleModel(block.model)) {
|
||||
this._groupLikeModels.set(block.id, block.model);
|
||||
}
|
||||
});
|
||||
|
||||
elementsYMap.observe(onElementsMapChange);
|
||||
|
||||
const subscription = this.doc.slots.blockUpdated.subscribe(payload => {
|
||||
switch (payload.type) {
|
||||
case 'add':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._groupLikeModels.set(payload.id, payload.model);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'delete':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._groupLikeModels.delete(payload.id);
|
||||
}
|
||||
{
|
||||
const group = this.getGroup(payload.id);
|
||||
if (group) {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(payload.model as GfxModel);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.deleted.subscribe(() => {
|
||||
elementsYMap.unobserve(onElementsMapChange);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private _propsToY(type: string, props: Record<string, unknown>) {
|
||||
const ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!ctor) {
|
||||
throw new Error(`Invalid element type: ${type}`);
|
||||
}
|
||||
|
||||
Object.entries(props).forEach(([key, val]) => {
|
||||
if (val instanceof Object) {
|
||||
if (Reflect.has(val, SURFACE_TEXT_UNIQ_IDENTIFIER)) {
|
||||
const yText = new Y.Text();
|
||||
yText.applyDelta(Reflect.get(val, 'delta'));
|
||||
Reflect.set(props, key, yText);
|
||||
}
|
||||
|
||||
if (Reflect.has(val, SURFACE_YMAP_UNIQ_IDENTIFIER)) {
|
||||
const childJson = Reflect.get(val, 'json') as Record<string, unknown>;
|
||||
const childrenYMap = new Y.Map<unknown>();
|
||||
|
||||
Object.keys(childJson).forEach(childId => {
|
||||
childrenYMap.set(childId, childJson[childId]);
|
||||
});
|
||||
Reflect.set(props, key, childrenYMap);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-expect-error ignore
|
||||
return ctor.propsToY ? ctor.propsToY(props) : props;
|
||||
}
|
||||
|
||||
private _watchGroupRelationChange() {
|
||||
const isGroup = (
|
||||
element: GfxPrimitiveElementModel
|
||||
): element is GfxGroupLikeElementModel =>
|
||||
element instanceof GfxGroupLikeElementModel;
|
||||
|
||||
const disposable = this.elementUpdated.subscribe(({ id, oldValues }) => {
|
||||
const element = this.getElementById(id)!;
|
||||
|
||||
if (
|
||||
isGroup(element) &&
|
||||
oldValues['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
this.deleteElement(id);
|
||||
}
|
||||
});
|
||||
this.deleted.subscribe(() => {
|
||||
disposable.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private _watchChildrenChange() {
|
||||
const updateIsEmpty = () => {
|
||||
this._isEmpty$.value =
|
||||
this._elementModels.size === 0 && this.children.length === 0;
|
||||
};
|
||||
|
||||
const disposables = new DisposableGroup();
|
||||
disposables.add(this.elementAdded.subscribe(updateIsEmpty));
|
||||
disposables.add(this.elementRemoved.subscribe(updateIsEmpty));
|
||||
this.doc.slots.blockUpdated.subscribe(payload => {
|
||||
if (['add', 'delete'].includes(payload.type)) {
|
||||
updateIsEmpty();
|
||||
}
|
||||
});
|
||||
this.deleted.subscribe(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
protected _extendElement(
|
||||
ctorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
>
|
||||
) {
|
||||
Object.assign(this._elementCtorMap, ctorMap);
|
||||
}
|
||||
|
||||
protected _init() {
|
||||
this._initElementModels();
|
||||
this._watchGroupRelationChange();
|
||||
this._watchChildrenChange();
|
||||
}
|
||||
|
||||
getConstructor(type: string) {
|
||||
return this._elementCtorMap[type];
|
||||
}
|
||||
|
||||
addElement<T extends object = Record<string, unknown>>(
|
||||
props: Partial<T> & { type: string }
|
||||
) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot add element in readonly mode');
|
||||
}
|
||||
|
||||
const middlewareCtx: MiddlewareCtx = {
|
||||
type: 'beforeAdd',
|
||||
payload: {
|
||||
type: props.type,
|
||||
props,
|
||||
},
|
||||
};
|
||||
|
||||
this._middlewares.forEach(mid => mid(middlewareCtx));
|
||||
|
||||
props = middlewareCtx.payload.props as Partial<T> & { type: string };
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
// @ts-expect-error ignore
|
||||
props.id = id;
|
||||
|
||||
const elementModel = this._createElementFromProps(props, {
|
||||
onChange: payload => {
|
||||
this.elementUpdated.next(payload);
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
elementModel.model.propsUpdated.next({ key });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this._elementModels.set(id, elementModel);
|
||||
|
||||
this.doc.transact(() => {
|
||||
this.elements.getValue()!.set(id, elementModel.model.yMap);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
addLocalElement(elem: GfxLocalElementModel) {
|
||||
this.localElements.add(elem);
|
||||
this.localElementAdded.next(elem);
|
||||
}
|
||||
|
||||
applyMiddlewares(middlewares: SurfaceMiddleware[]) {
|
||||
this._middlewares = middlewares;
|
||||
}
|
||||
|
||||
deleteElement(id: string) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot remove element in readonly mode');
|
||||
}
|
||||
|
||||
if (!this.hasElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.transact(() => {
|
||||
const element = this.getElementById(id)!;
|
||||
const group = this.getGroup(id);
|
||||
|
||||
if (element instanceof GfxGroupLikeElementModel) {
|
||||
element.childIds.forEach(childId => {
|
||||
if (this.hasElementById(childId)) {
|
||||
this.deleteElement(childId);
|
||||
} else if (this.doc.hasBlock(childId)) {
|
||||
this.doc.deleteBlock(this.doc.getBlock(childId)!.model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group?.removeChild(element as GfxModel);
|
||||
|
||||
this.elements.getValue()!.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
deleteLocalElement(elem: GfxLocalElementModel) {
|
||||
if (this.localElements.delete(elem)) {
|
||||
this.localElementDeleted.next(elem);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
this.elementAdded.complete();
|
||||
this.elementRemoved.complete();
|
||||
this.elementUpdated.complete();
|
||||
|
||||
this._elementModels.forEach(({ unmount }) => unmount());
|
||||
this._elementModels.clear();
|
||||
}
|
||||
|
||||
getElementById(id: string): GfxPrimitiveElementModel | null {
|
||||
return this._elementModels.get(id)?.model ?? null;
|
||||
}
|
||||
|
||||
getElementsByType(type: string): GfxPrimitiveElementModel[] {
|
||||
return this._elementTypeMap.get(type) || [];
|
||||
}
|
||||
|
||||
getGroup(elem: string | GfxModel): GfxGroupModel | null {
|
||||
elem =
|
||||
typeof elem === 'string'
|
||||
? ((this.getElementById(elem) ??
|
||||
this.doc.getBlock(elem)?.model) as GfxModel)
|
||||
: elem;
|
||||
|
||||
if (!elem) return null;
|
||||
|
||||
assertType<GfxModel>(elem);
|
||||
|
||||
for (const group of this._groupLikeModels.values()) {
|
||||
if (group.hasChild(elem)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getGroups(id: string): GfxGroupModel[] {
|
||||
const groups: GfxGroupModel[] = [];
|
||||
const visited = new Set<GfxGroupModel>();
|
||||
let group = this.getGroup(id);
|
||||
|
||||
while (group) {
|
||||
if (visited.has(group)) {
|
||||
console.warn('Exists a cycle in group relation');
|
||||
break;
|
||||
}
|
||||
visited.add(group);
|
||||
groups.push(group);
|
||||
group = this.getGroup(group.id);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
hasElementById(id: string): boolean {
|
||||
return this._elementModels.has(id);
|
||||
}
|
||||
|
||||
isGroup(element: GfxModel): element is GfxModel & GfxGroupCompatibleInterface;
|
||||
isGroup(id: string): boolean;
|
||||
isGroup(element: string | GfxModel): boolean {
|
||||
if (typeof element === 'string') {
|
||||
const el = this.getElementById(element);
|
||||
if (el) return isGfxGroupCompatibleModel(el);
|
||||
|
||||
const blockModel = this.doc.getBlock(element)?.model;
|
||||
if (blockModel) return isGfxGroupCompatibleModel(blockModel);
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return isGfxGroupCompatibleModel(element);
|
||||
}
|
||||
}
|
||||
|
||||
updateElement<T extends object = Record<string, unknown>>(
|
||||
id: string,
|
||||
props: Partial<T>
|
||||
) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot update element in readonly mode');
|
||||
}
|
||||
|
||||
const elementModel = this.getElementById(id);
|
||||
|
||||
if (!elementModel) {
|
||||
throw new Error(`Element ${id} is not found`);
|
||||
}
|
||||
|
||||
this.doc.transact(() => {
|
||||
props = this._propsToY(
|
||||
elementModel.type,
|
||||
props as Record<string, unknown>
|
||||
) as T;
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
// @ts-expect-error ignore
|
||||
elementModel[key] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
409
blocksuite/framework/std/src/gfx/selection.ts
Normal file
409
blocksuite/framework/std/src/gfx/selection.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
getCommonBoundWithRotation,
|
||||
type IPoint,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import {
|
||||
BlockSelection,
|
||||
CursorSelection,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '../selection/index.js';
|
||||
import type { GfxController } from './controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from './extension.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import { GfxGroupLikeElementModel } from './model/surface/element-model.js';
|
||||
|
||||
export interface SurfaceSelectionState {
|
||||
/**
|
||||
* The selected elements. Could be blocks or canvas elements
|
||||
*/
|
||||
elements: string[];
|
||||
|
||||
/**
|
||||
* Indicate whether the selected element is in editing mode
|
||||
*/
|
||||
editing?: boolean;
|
||||
|
||||
/**
|
||||
* Cannot be operated, only box is displayed
|
||||
*/
|
||||
inoperable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GfxSelectionManager is just a wrapper of std selection providing
|
||||
* convenient method and states in gfx
|
||||
*/
|
||||
export class GfxSelectionManager extends GfxExtension {
|
||||
static override key = 'gfxSelection';
|
||||
|
||||
private _activeGroup: GfxGroupLikeElementModel | null = null;
|
||||
|
||||
private _cursorSelection: CursorSelection | null = null;
|
||||
|
||||
private _lastSurfaceSelections: SurfaceSelection[] = [];
|
||||
|
||||
private _remoteCursorSelectionMap = new Map<number, CursorSelection>();
|
||||
|
||||
private _remoteSelectedSet = new Set<string>();
|
||||
|
||||
private _remoteSurfaceSelectionsMap = new Map<number, SurfaceSelection[]>();
|
||||
|
||||
private _selectedSet = new Set<string>();
|
||||
|
||||
private _surfaceSelections: SurfaceSelection[] = [];
|
||||
|
||||
disposable: DisposableGroup = new DisposableGroup();
|
||||
|
||||
readonly slots = {
|
||||
updated: new Subject<SurfaceSelection[]>(),
|
||||
remoteUpdated: new Subject<void>(),
|
||||
|
||||
cursorUpdated: new Subject<CursorSelection>(),
|
||||
remoteCursorUpdated: new Subject<void>(),
|
||||
};
|
||||
|
||||
get activeGroup() {
|
||||
return this._activeGroup;
|
||||
}
|
||||
|
||||
get cursorSelection() {
|
||||
return this._cursorSelection;
|
||||
}
|
||||
|
||||
get editing() {
|
||||
return this.surfaceSelections.some(sel => sel.editing);
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.surfaceSelections.every(sel => sel.elements.length === 0);
|
||||
}
|
||||
|
||||
get firstElement() {
|
||||
return this.selectedElements[0];
|
||||
}
|
||||
|
||||
get inoperable() {
|
||||
return this.surfaceSelections.some(sel => sel.inoperable);
|
||||
}
|
||||
|
||||
get lastSurfaceSelections() {
|
||||
return this._lastSurfaceSelections;
|
||||
}
|
||||
|
||||
get remoteCursorSelectionMap() {
|
||||
return this._remoteCursorSelectionMap;
|
||||
}
|
||||
|
||||
get remoteSelectedSet() {
|
||||
return this._remoteSelectedSet;
|
||||
}
|
||||
|
||||
get remoteSurfaceSelectionsMap() {
|
||||
return this._remoteSurfaceSelectionsMap;
|
||||
}
|
||||
|
||||
get selectedBound() {
|
||||
return getCommonBoundWithRotation(this.selectedElements);
|
||||
}
|
||||
|
||||
get selectedElements() {
|
||||
const elements: GfxModel[] = [];
|
||||
|
||||
this.selectedIds.forEach(id => {
|
||||
const el = this.gfx.getElementById(id) as GfxModel;
|
||||
el && elements.push(el);
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
get selectedIds() {
|
||||
return [...this._selectedSet];
|
||||
}
|
||||
|
||||
get selectedSet() {
|
||||
return this._selectedSet;
|
||||
}
|
||||
|
||||
get stdSelection() {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
get surfaceModel() {
|
||||
return this.gfx.surface;
|
||||
}
|
||||
|
||||
get surfaceSelections() {
|
||||
return this._surfaceSelections;
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController): void {
|
||||
Object.defineProperty(gfx, 'selection', {
|
||||
get() {
|
||||
return this.std.get(GfxExtensionIdentifier('gfxSelection'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.stdSelection.clear();
|
||||
|
||||
this.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
clearLast() {
|
||||
this._lastSurfaceSelections = [];
|
||||
}
|
||||
|
||||
equals(selection: SurfaceSelection[]) {
|
||||
let count = 0;
|
||||
let editing = false;
|
||||
const exist = selection.every(sel => {
|
||||
const exist = sel.elements.every(id => this._selectedSet.has(id));
|
||||
|
||||
if (exist) {
|
||||
count += sel.elements.length;
|
||||
}
|
||||
|
||||
if (sel.editing) editing = true;
|
||||
|
||||
return exist;
|
||||
});
|
||||
|
||||
return (
|
||||
exist && count === this._selectedSet.size && editing === this.editing
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the element is selected in local
|
||||
* @param element
|
||||
*/
|
||||
has(element: string) {
|
||||
return this._selectedSet.has(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if element is selected by remote peers
|
||||
* @param element
|
||||
*/
|
||||
hasRemote(element: string) {
|
||||
return this._remoteSelectedSet.has(element);
|
||||
}
|
||||
|
||||
isEmpty(selections: SurfaceSelection[]) {
|
||||
return selections.every(sel => sel.elements.length === 0);
|
||||
}
|
||||
|
||||
isInSelectedRect(viewX: number, viewY: number) {
|
||||
const selected = this.selectedElements;
|
||||
if (!selected.length) return false;
|
||||
|
||||
const commonBound = getCommonBoundWithRotation(selected);
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(viewX, viewY);
|
||||
if (commonBound && commonBound.isPointInBound([modelX, modelY])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.disposable.add(
|
||||
this.stdSelection.slots.changed.subscribe(selections => {
|
||||
const { cursor = [], surface = [] } = groupBy(selections, sel => {
|
||||
if (sel.is(SurfaceSelection)) {
|
||||
return 'surface';
|
||||
} else if (sel.is(CursorSelection)) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
});
|
||||
|
||||
assertType<CursorSelection[]>(cursor);
|
||||
assertType<SurfaceSelection[]>(surface);
|
||||
|
||||
if (cursor[0] && !this.cursorSelection?.equals(cursor[0])) {
|
||||
this._cursorSelection = cursor[0];
|
||||
this.slots.cursorUpdated.next(cursor[0]);
|
||||
}
|
||||
|
||||
if ((surface.length === 0 && this.empty) || this.equals(surface)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastSurfaceSelections = this.surfaceSelections;
|
||||
this._surfaceSelections = surface;
|
||||
this._selectedSet = new Set<string>();
|
||||
|
||||
surface.forEach(sel =>
|
||||
sel.elements.forEach(id => {
|
||||
this._selectedSet.add(id);
|
||||
})
|
||||
);
|
||||
|
||||
this.slots.updated.next(this.surfaceSelections);
|
||||
})
|
||||
);
|
||||
|
||||
this.disposable.add(
|
||||
this.stdSelection.slots.remoteChanged.subscribe(states => {
|
||||
const surfaceMap = new Map<number, SurfaceSelection[]>();
|
||||
const cursorMap = new Map<number, CursorSelection>();
|
||||
const selectedSet = new Set<string>();
|
||||
|
||||
states.forEach((selections, id) => {
|
||||
let hasTextSelection = false;
|
||||
let hasBlockSelection = false;
|
||||
|
||||
selections.forEach(selection => {
|
||||
if (selection.is(TextSelection)) {
|
||||
hasTextSelection = true;
|
||||
}
|
||||
|
||||
if (selection.is(BlockSelection)) {
|
||||
hasBlockSelection = true;
|
||||
}
|
||||
|
||||
if (selection.is(SurfaceSelection)) {
|
||||
const surfaceSelections = surfaceMap.get(id) ?? [];
|
||||
surfaceSelections.push(selection);
|
||||
surfaceMap.set(id, surfaceSelections);
|
||||
|
||||
selection.elements.forEach(id => selectedSet.add(id));
|
||||
}
|
||||
|
||||
if (selection.is(CursorSelection)) {
|
||||
cursorMap.set(id, selection);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasBlockSelection || hasTextSelection) {
|
||||
surfaceMap.delete(id);
|
||||
}
|
||||
|
||||
if (hasTextSelection) {
|
||||
cursorMap.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
this._remoteCursorSelectionMap = cursorMap;
|
||||
this._remoteSurfaceSelectionsMap = surfaceMap;
|
||||
this._remoteSelectedSet = selectedSet;
|
||||
|
||||
this.slots.remoteUpdated.next();
|
||||
this.slots.remoteCursorUpdated.next();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
set(selection: SurfaceSelectionState | SurfaceSelection[]) {
|
||||
if (Array.isArray(selection)) {
|
||||
this.stdSelection.setGroup(
|
||||
'gfx',
|
||||
this.cursorSelection ? [...selection, this.cursorSelection] : selection
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { blocks = [], elements = [] } = groupBy(selection.elements, id => {
|
||||
return this.std.store.hasBlock(id) ? 'blocks' : 'elements';
|
||||
});
|
||||
let instances: (SurfaceSelection | CursorSelection)[] = [];
|
||||
|
||||
if (elements.length > 0 && this.surfaceModel) {
|
||||
instances.push(
|
||||
this.stdSelection.create(
|
||||
SurfaceSelection,
|
||||
this.surfaceModel.id,
|
||||
elements,
|
||||
selection.editing ?? false,
|
||||
selection.inoperable
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length > 0) {
|
||||
instances = instances.concat(
|
||||
blocks.map(blockId =>
|
||||
this.stdSelection.create(
|
||||
SurfaceSelection,
|
||||
blockId,
|
||||
[blockId],
|
||||
selection.editing ?? false,
|
||||
selection.inoperable
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.stdSelection.setGroup(
|
||||
'gfx',
|
||||
this.cursorSelection
|
||||
? instances.concat([this.cursorSelection])
|
||||
: instances
|
||||
);
|
||||
|
||||
if (instances.length > 0) {
|
||||
this.stdSelection.setGroup('note', []);
|
||||
}
|
||||
|
||||
if (
|
||||
selection.elements.length === 1 &&
|
||||
this.firstElement instanceof GfxGroupLikeElementModel
|
||||
) {
|
||||
this._activeGroup = this.firstElement;
|
||||
} else {
|
||||
if (
|
||||
this.selectedElements.some(ele => ele.group !== this._activeGroup) ||
|
||||
this.selectedElements.length === 0
|
||||
) {
|
||||
this._activeGroup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the selection state of single element
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
toggle(element: GfxModel | string) {
|
||||
element = typeof element === 'string' ? element : element.id;
|
||||
|
||||
this.set({
|
||||
elements: this.has(element)
|
||||
? this.selectedIds.filter(id => id !== element)
|
||||
: [...this.selectedIds, element],
|
||||
});
|
||||
}
|
||||
|
||||
setCursor(cursor: CursorSelection | IPoint) {
|
||||
const instance = this.stdSelection.create(
|
||||
CursorSelection,
|
||||
cursor.x,
|
||||
cursor.y
|
||||
);
|
||||
|
||||
this.stdSelection.setGroup('gfx', [...this.surfaceSelections, instance]);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
declare module './controller.js' {
|
||||
interface GfxController {
|
||||
readonly selection: GfxSelectionManager;
|
||||
}
|
||||
}
|
||||
61
blocksuite/framework/std/src/gfx/surface-middleware.ts
Normal file
61
blocksuite/framework/std/src/gfx/surface-middleware.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js';
|
||||
import { StdIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/std-scope.js';
|
||||
import { onSurfaceAdded } from '../utils/gfx.js';
|
||||
import { GfxControllerIdentifier } from './identifiers.js';
|
||||
import type { SurfaceMiddleware } from './model/surface/surface-model.js';
|
||||
|
||||
export abstract class SurfaceMiddlewareBuilder extends Extension {
|
||||
static key: string = '';
|
||||
|
||||
abstract middleware: SurfaceMiddleware;
|
||||
|
||||
get gfx() {
|
||||
return this.std.provider.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
constructor(protected std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'The surface middleware builder should have a static key property.'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(SurfaceMiddlewareBuilderIdentifier(this.key), this, [
|
||||
StdIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
mounted(): void {}
|
||||
|
||||
unmounted(): void {}
|
||||
}
|
||||
|
||||
export const SurfaceMiddlewareBuilderIdentifier =
|
||||
createIdentifier<SurfaceMiddlewareBuilder>('SurfaceMiddlewareBuilder');
|
||||
|
||||
export class SurfaceMiddlewareExtension extends LifeCycleWatcher {
|
||||
static override key: string = 'surfaceMiddleware';
|
||||
|
||||
override mounted(): void {
|
||||
const builders = Array.from(
|
||||
this.std.provider.getAll(SurfaceMiddlewareBuilderIdentifier).values()
|
||||
);
|
||||
|
||||
const dispose = onSurfaceAdded(this.std.store, surface => {
|
||||
if (surface) {
|
||||
surface.applyMiddlewares(builders.map(builder => builder.middleware));
|
||||
queueMicrotask(() => dispose());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
554
blocksuite/framework/std/src/gfx/tool/tool-controller.ts
Normal file
554
blocksuite/framework/std/src/gfx/tool/tool-controller.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import type { ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IBound, IPoint } from '@blocksuite/global/gfx';
|
||||
import { Signal } from '@preact/signals-core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import {
|
||||
type BaseTool,
|
||||
type GfxToolsFullOptionValue,
|
||||
type GfxToolsMap,
|
||||
type GfxToolsOption,
|
||||
ToolIdentifier,
|
||||
} from './tool.js';
|
||||
|
||||
type BuiltInHookEvent<T> = {
|
||||
data: T;
|
||||
preventDefault(): void;
|
||||
};
|
||||
|
||||
type BuiltInEventMap = {
|
||||
beforeToolUpdate: BuiltInHookEvent<{
|
||||
toolName: keyof GfxToolsMap;
|
||||
}>;
|
||||
toolUpdate: BuiltInHookEvent<{ toolName: keyof GfxToolsMap }>;
|
||||
};
|
||||
|
||||
type BuiltInSlotContext = {
|
||||
[K in keyof BuiltInEventMap]: { event: K } & BuiltInEventMap[K];
|
||||
}[SupportedHooks];
|
||||
|
||||
export type SupportedHooks = keyof BuiltInEventMap;
|
||||
|
||||
const supportedEvents = [
|
||||
'dragStart',
|
||||
'dragEnd',
|
||||
'dragMove',
|
||||
'pointerMove',
|
||||
'contextMenu',
|
||||
'pointerDown',
|
||||
'pointerUp',
|
||||
'click',
|
||||
'doubleClick',
|
||||
'tripleClick',
|
||||
'pointerOut',
|
||||
] as const;
|
||||
|
||||
export type SupportedEvents = (typeof supportedEvents)[number];
|
||||
|
||||
export enum MouseButton {
|
||||
FIFTH = 4,
|
||||
FOURTH = 3,
|
||||
MAIN = 0,
|
||||
MIDDLE = 1,
|
||||
SECONDARY = 2,
|
||||
}
|
||||
|
||||
export interface ToolEventTarget {
|
||||
/**
|
||||
* Add a hook before the event is handled by the tool.
|
||||
* Return false to prevent the tool from handling the event.
|
||||
* @param evtName
|
||||
* @param handler
|
||||
*/
|
||||
addHook<K extends SupportedHooks | SupportedEvents>(
|
||||
evtName: K,
|
||||
handler: (
|
||||
evtState: K extends SupportedHooks
|
||||
? BuiltInEventMap[K]
|
||||
: PointerEventState
|
||||
) => void | boolean
|
||||
): void;
|
||||
}
|
||||
|
||||
export const eventTarget = Symbol('eventTarget');
|
||||
|
||||
export class ToolController extends GfxExtension {
|
||||
static override key = 'ToolController';
|
||||
|
||||
private readonly _builtInHookSlot = new Subject<BuiltInSlotContext>();
|
||||
|
||||
private readonly _disposableGroup = new DisposableGroup();
|
||||
|
||||
private readonly _toolOption$ = new Signal<GfxToolsFullOptionValue>(
|
||||
{} as GfxToolsFullOptionValue
|
||||
);
|
||||
|
||||
private readonly _tools = new Map<string, BaseTool>();
|
||||
|
||||
readonly currentToolName$ = new Signal<keyof GfxToolsMap>();
|
||||
|
||||
readonly dragging$ = new Signal<boolean>(false);
|
||||
|
||||
/**
|
||||
* The area that is being dragged.
|
||||
* The coordinates are in browser space.
|
||||
*/
|
||||
readonly draggingViewArea$ = new Signal<
|
||||
IBound & {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
>({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The last mouse move position
|
||||
* The coordinates are in browser space
|
||||
*/
|
||||
readonly lastMousePos$ = new Signal<IPoint>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
get currentTool$() {
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return self._tools.get(self.currentToolName$.value);
|
||||
},
|
||||
peek() {
|
||||
return self._tools.get(self.currentToolName$.peek());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get currentToolOption$() {
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
peek() {
|
||||
const option = self._toolOption$.peek() as unknown as { type: string };
|
||||
|
||||
if (!option.type) {
|
||||
option.type = '';
|
||||
}
|
||||
|
||||
return option as GfxToolsFullOptionValue;
|
||||
},
|
||||
|
||||
get value(): GfxToolsFullOptionValue {
|
||||
const option = self._toolOption$.value as unknown as { type: string };
|
||||
|
||||
if (!option.type) {
|
||||
option.type = '';
|
||||
}
|
||||
|
||||
return option as GfxToolsFullOptionValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The area that is being dragged.
|
||||
* The coordinates are in model space.
|
||||
*/
|
||||
get draggingArea$() {
|
||||
const compute = (peek: boolean) => {
|
||||
const area = peek
|
||||
? this.draggingViewArea$.peek()
|
||||
: this.draggingViewArea$.value;
|
||||
const [startX, startY] = this.gfx.viewport.toModelCoord(
|
||||
area.startX,
|
||||
area.startY
|
||||
);
|
||||
const [endX, endY] = this.gfx.viewport.toModelCoord(area.endX, area.endY);
|
||||
|
||||
return {
|
||||
x: Math.min(startX, endX),
|
||||
y: Math.min(startY, endY),
|
||||
w: Math.abs(endX - startX),
|
||||
h: Math.abs(endY - startY),
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
value() {
|
||||
return compute(false);
|
||||
},
|
||||
peek() {
|
||||
return compute(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController) {
|
||||
Object.defineProperty(gfx, 'tool', {
|
||||
get() {
|
||||
return this.std.provider.get(ToolControllerIdentifier);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _createBuiltInHookCtx<K extends keyof BuiltInEventMap>(
|
||||
eventName: K,
|
||||
data: BuiltInEventMap[K]['data']
|
||||
): {
|
||||
prevented: boolean;
|
||||
slotCtx: BuiltInSlotContext;
|
||||
} {
|
||||
const ctx = {
|
||||
prevented: false,
|
||||
slotCtx: {
|
||||
event: eventName,
|
||||
data,
|
||||
preventDefault() {
|
||||
ctx.prevented = true;
|
||||
},
|
||||
} as BuiltInSlotContext,
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private _initializeEvents() {
|
||||
const hooks: Record<
|
||||
string,
|
||||
((
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean)[]
|
||||
> = {};
|
||||
/**
|
||||
* Invoke the hook and the tool handler.
|
||||
* @returns false if the handler is prevented by the hook
|
||||
*/
|
||||
const invokeToolHandler = (
|
||||
evtName: SupportedEvents,
|
||||
evt: PointerEventState,
|
||||
tool?: BaseTool
|
||||
) => {
|
||||
const evtHooks = hooks[evtName];
|
||||
const stopHandler = evtHooks?.reduce((pre, hook) => {
|
||||
return pre || hook(evt) === false;
|
||||
}, false);
|
||||
|
||||
tool = tool ?? this.currentTool$.peek();
|
||||
|
||||
if (stopHandler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
tool?.[evtName](evt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ExecutionError,
|
||||
`Error occurred while executing ${evtName} handler of tool "${tool?.toolName}"`,
|
||||
{
|
||||
cause: e as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook into the event lifecycle.
|
||||
* All hooks will be executed despite the current active tool.
|
||||
* This is useful for tools that need to perform some action before an event is handled.
|
||||
* @param evtName
|
||||
* @param handler
|
||||
*/
|
||||
const addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
|
||||
hooks[evtName] = hooks[evtName] ?? [];
|
||||
hooks[evtName].push(
|
||||
handler as (
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean
|
||||
);
|
||||
|
||||
return () => {
|
||||
const idx = hooks[evtName].indexOf(
|
||||
handler as (
|
||||
evtState: PointerEventState | BuiltInSlotContext
|
||||
) => undefined | boolean
|
||||
);
|
||||
if (idx !== -1) {
|
||||
hooks[evtName].splice(idx, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let dragContext: {
|
||||
tool: BaseTool;
|
||||
} | null = null;
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragStart', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
if (
|
||||
evt.button === MouseButton.SECONDARY &&
|
||||
!this.currentTool$.peek()?.allowDragWithRightButton
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.button === MouseButton.MIDDLE) {
|
||||
evt.raw.preventDefault();
|
||||
}
|
||||
|
||||
this.dragging$.value = true;
|
||||
this.draggingViewArea$.value = {
|
||||
startX: evt.x,
|
||||
startY: evt.y,
|
||||
endX: evt.x,
|
||||
endY: evt.y,
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
|
||||
// this means the dragEnd event is not even fired
|
||||
// so we need to manually call the dragEnd method
|
||||
if (dragContext?.tool) {
|
||||
dragContext.tool.dragEnd(evt);
|
||||
dragContext = null;
|
||||
}
|
||||
|
||||
if (invokeToolHandler('dragStart', evt)) {
|
||||
dragContext = this.currentTool$.peek()
|
||||
? {
|
||||
tool: this.currentTool$.peek()!,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragMove', ctx => {
|
||||
if (!this.dragging$.peek()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = ctx.get('pointerState');
|
||||
const draggingStart = {
|
||||
x: this.draggingArea$.peek().startX,
|
||||
y: this.draggingArea$.peek().startY,
|
||||
originX: this.draggingViewArea$.peek().startX,
|
||||
originY: this.draggingViewArea$.peek().startY,
|
||||
};
|
||||
|
||||
this.draggingViewArea$.value = {
|
||||
...this.draggingViewArea$.peek(),
|
||||
w: Math.abs(evt.x - draggingStart.originX),
|
||||
h: Math.abs(evt.y - draggingStart.originY),
|
||||
x: Math.min(evt.x, draggingStart.originX),
|
||||
y: Math.min(evt.y, draggingStart.originY),
|
||||
endX: evt.x,
|
||||
endY: evt.y,
|
||||
};
|
||||
|
||||
invokeToolHandler('dragMove', evt, dragContext?.tool);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('dragEnd', ctx => {
|
||||
if (!this.dragging$.peek()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dragging$.value = false;
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
// if the tool dragEnd is prevented by the hook, call the dragEnd method manually
|
||||
// this guarantee the dragStart and dragEnd events are always called together
|
||||
if (
|
||||
!invokeToolHandler('dragEnd', evt, dragContext?.tool) &&
|
||||
dragContext?.tool
|
||||
) {
|
||||
dragContext.tool.dragEnd(evt);
|
||||
}
|
||||
|
||||
dragContext = null;
|
||||
this.draggingViewArea$.value = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('pointerMove', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
this.lastMousePos$.value = {
|
||||
x: evt.x,
|
||||
y: evt.y,
|
||||
};
|
||||
|
||||
invokeToolHandler('pointerMove', evt);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add('contextMenu', ctx => {
|
||||
const evt = ctx.get('defaultState');
|
||||
|
||||
// when in editing mode, allow context menu to pop up
|
||||
if (this.gfx.selection.editing) return;
|
||||
|
||||
evt.event.preventDefault();
|
||||
})
|
||||
);
|
||||
|
||||
supportedEvents.slice(5).forEach(evtName => {
|
||||
this._disposableGroup.add(
|
||||
this.std.event.add(evtName, ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
|
||||
invokeToolHandler(evtName, evt);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this._builtInHookSlot.subscribe(evt => {
|
||||
hooks[evt.event]?.forEach(hook => hook(evt));
|
||||
});
|
||||
|
||||
return {
|
||||
addHook,
|
||||
};
|
||||
}
|
||||
|
||||
private _register(tools: BaseTool) {
|
||||
if (this._tools.has(tools.toolName)) {
|
||||
this._tools.get(tools.toolName)?.unmounted();
|
||||
}
|
||||
|
||||
this._tools.set(tools.toolName, tools);
|
||||
tools.mounted();
|
||||
}
|
||||
|
||||
get<K extends keyof GfxToolsMap>(key: K): GfxToolsMap[K] {
|
||||
return this._tools.get(key) as GfxToolsMap[K];
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
const { addHook } = this._initializeEvents();
|
||||
|
||||
const eventTarget: ToolEventTarget = {
|
||||
addHook,
|
||||
};
|
||||
|
||||
this.std.provider.getAll(ToolIdentifier).forEach(tool => {
|
||||
// @ts-expect-error ignore
|
||||
tool['eventTarget'] = eventTarget;
|
||||
this._register(tool);
|
||||
});
|
||||
}
|
||||
|
||||
setTool(toolName: GfxToolsFullOptionValue, ...args: [void]): void;
|
||||
setTool<K extends keyof GfxToolsMap>(
|
||||
toolName: K,
|
||||
...args: K extends keyof GfxToolsOption
|
||||
? [option: GfxToolsOption[K]]
|
||||
: [void]
|
||||
): void;
|
||||
setTool<K extends keyof GfxToolsMap>(
|
||||
toolName: K | GfxToolsFullOptionValue,
|
||||
...args: K extends keyof GfxToolsOption
|
||||
? [option: GfxToolsOption[K]]
|
||||
: [void]
|
||||
): void {
|
||||
const option = typeof toolName === 'string' ? args[0] : toolName;
|
||||
const toolNameStr =
|
||||
typeof toolName === 'string'
|
||||
? toolName
|
||||
: ((toolName as { type: string }).type as K);
|
||||
|
||||
const beforeUpdateCtx = this._createBuiltInHookCtx('beforeToolUpdate', {
|
||||
toolName: toolNameStr,
|
||||
});
|
||||
this._builtInHookSlot.next(beforeUpdateCtx.slotCtx);
|
||||
|
||||
if (beforeUpdateCtx.prevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// explicitly clear the selection when switching tools
|
||||
this.gfx.selection.set({ elements: [] });
|
||||
|
||||
this.currentTool$.peek()?.deactivate();
|
||||
this.currentToolName$.value = toolNameStr;
|
||||
|
||||
const currentTool = this.currentTool$.peek();
|
||||
if (!currentTool) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
`Tool "${this.currentToolName$.value}" is not defined`
|
||||
);
|
||||
}
|
||||
|
||||
currentTool.activatedOption = option ?? {};
|
||||
this._toolOption$.value = {
|
||||
...currentTool.activatedOption,
|
||||
type: toolNameStr,
|
||||
} as GfxToolsFullOptionValue;
|
||||
currentTool.activate(currentTool.activatedOption);
|
||||
|
||||
const afterUpdateCtx = this._createBuiltInHookCtx('toolUpdate', {
|
||||
toolName: toolNameStr,
|
||||
});
|
||||
this._builtInHookSlot.next(afterUpdateCtx.slotCtx);
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
this.currentTool$.peek()?.deactivate();
|
||||
this._tools.forEach(tool => {
|
||||
tool.unmounted();
|
||||
tool['disposable'].dispose();
|
||||
});
|
||||
this._builtInHookSlot.complete();
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolControllerIdentifier = GfxExtensionIdentifier(
|
||||
'ToolController'
|
||||
) as ServiceIdentifier<ToolController>;
|
||||
|
||||
declare module '../controller.js' {
|
||||
interface GfxController {
|
||||
readonly tool: ToolController;
|
||||
}
|
||||
}
|
||||
125
blocksuite/framework/std/src/gfx/tool/tool.ts
Normal file
125
blocksuite/framework/std/src/gfx/tool/tool.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxControllerIdentifier } from '../identifiers.js';
|
||||
import type { ToolEventTarget } from './tool-controller.js';
|
||||
|
||||
export abstract class BaseTool<
|
||||
Option = Record<string, unknown>,
|
||||
> extends Extension {
|
||||
static toolName: string = '';
|
||||
|
||||
private readonly eventTarget!: ToolEventTarget;
|
||||
|
||||
activatedOption: Option = {} as Option;
|
||||
|
||||
addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
|
||||
this.eventTarget.addHook(evtName, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* The `disposable` will be disposed when the tool is unloaded.
|
||||
*/
|
||||
protected readonly disposable = new DisposableGroup();
|
||||
|
||||
get active() {
|
||||
return this.gfx.tool.currentTool$.peek() === this;
|
||||
}
|
||||
|
||||
get allowDragWithRightButton() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get controller() {
|
||||
return this.gfx.tool;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.gfx.doc;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
get toolName() {
|
||||
return (this.constructor as typeof BaseTool).toolName;
|
||||
}
|
||||
|
||||
constructor(readonly gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container): void {
|
||||
if (!this.toolName) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
`The tool constructor '${this.name}' should have a static 'toolName' property.`
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(ToolIdentifier(this.toolName), this, [GfxControllerIdentifier]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the tool is activated.
|
||||
* @param _ - The data passed as second argument when calling `ToolController.use`.
|
||||
*/
|
||||
activate(_: Option): void {}
|
||||
|
||||
click(_: PointerEventState): void {}
|
||||
|
||||
contextMenu(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is deactivated.
|
||||
*/
|
||||
deactivate(): void {}
|
||||
|
||||
doubleClick(_: PointerEventState): void {}
|
||||
|
||||
dragEnd(_: PointerEventState): void {}
|
||||
|
||||
dragMove(_: PointerEventState): void {}
|
||||
|
||||
dragStart(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is registered.
|
||||
*/
|
||||
mounted(): void {}
|
||||
|
||||
pointerDown(_: PointerEventState): void {}
|
||||
|
||||
pointerMove(_: PointerEventState): void {}
|
||||
|
||||
pointerOut(_: PointerEventState): void {}
|
||||
|
||||
pointerUp(_: PointerEventState): void {}
|
||||
|
||||
tripleClick(_: PointerEventState): void {}
|
||||
|
||||
/**
|
||||
* Called when the tool is unloaded, usually when the whole `ToolController` is destroyed.
|
||||
*/
|
||||
unmounted(): void {}
|
||||
}
|
||||
|
||||
export const ToolIdentifier = createIdentifier<BaseTool>('GfxTool');
|
||||
|
||||
export interface GfxToolsMap {}
|
||||
|
||||
export interface GfxToolsOption {}
|
||||
|
||||
export type GfxToolsFullOption = {
|
||||
[Key in keyof GfxToolsMap]: Key extends keyof GfxToolsOption
|
||||
? { type: Key } & GfxToolsOption[Key]
|
||||
: { type: Key };
|
||||
};
|
||||
|
||||
export type GfxToolsFullOptionValue =
|
||||
GfxToolsFullOption[keyof GfxToolsFullOption];
|
||||
139
blocksuite/framework/std/src/gfx/view/view-manager.ts
Normal file
139
blocksuite/framework/std/src/gfx/view/view-manager.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
|
||||
import { onSurfaceAdded } from '../../utils/gfx.js';
|
||||
import {
|
||||
type GfxBlockComponent,
|
||||
isGfxBlockComponent,
|
||||
} from '../../view/index.js';
|
||||
import type { GfxController } from '../controller.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import type { GfxModel } from '../model/model.js';
|
||||
import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
|
||||
import type { SurfaceBlockModel } from '../model/surface/surface-model.js';
|
||||
import {
|
||||
GfxElementModelView,
|
||||
GfxElementModelViewExtIdentifier,
|
||||
} from './view.js';
|
||||
|
||||
export class ViewManager extends GfxExtension {
|
||||
static override key = 'viewManager';
|
||||
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
private readonly _viewCtorMap = new Map<string, typeof GfxElementModelView>();
|
||||
|
||||
private readonly _viewMap = new Map<string, GfxElementModelView>();
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController): void {
|
||||
Object.defineProperty(gfx, 'view', {
|
||||
get() {
|
||||
return this.std.get(GfxExtensionIdentifier('viewManager'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get(
|
||||
model: GfxModel | GfxLocalElementModel | string
|
||||
): GfxElementModelView | GfxBlockComponent | null {
|
||||
model = typeof model === 'string' ? model : model.id;
|
||||
|
||||
if (this._viewMap.has(model)) {
|
||||
return this._viewMap.get(model)!;
|
||||
}
|
||||
|
||||
const blockView = this.std.view.getBlock(model);
|
||||
|
||||
if (blockView && isGfxBlockComponent(blockView)) {
|
||||
return blockView;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
this.std.provider
|
||||
.getAll(GfxElementModelViewExtIdentifier)
|
||||
.forEach(viewCtor => {
|
||||
this._viewCtorMap.set(viewCtor.type, viewCtor);
|
||||
});
|
||||
|
||||
const updateViewOnElementChange = (surface: SurfaceBlockModel) => {
|
||||
const createView = (
|
||||
model: GfxPrimitiveElementModel | GfxLocalElementModel
|
||||
) => {
|
||||
const ViewCtor =
|
||||
this._viewCtorMap.get(model.type) ?? GfxElementModelView;
|
||||
const view = new ViewCtor(model, this.gfx);
|
||||
|
||||
this._viewMap.set(model.id, view);
|
||||
view.onCreated();
|
||||
};
|
||||
|
||||
this._disposable.add(
|
||||
surface.elementAdded.subscribe(payload => {
|
||||
const model = surface.getElementById(payload.id)!;
|
||||
createView(model);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.elementRemoved.subscribe(elem => {
|
||||
const view = this._viewMap.get(elem.id);
|
||||
this._viewMap.delete(elem.id);
|
||||
view?.onDestroyed();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.localElementAdded.subscribe(model => {
|
||||
createView(model);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
surface.localElementDeleted.subscribe(model => {
|
||||
const view = this._viewMap.get(model.id);
|
||||
this._viewMap.delete(model.id);
|
||||
view?.onDestroyed();
|
||||
})
|
||||
);
|
||||
|
||||
surface.localElementModels.forEach(model => {
|
||||
createView(model);
|
||||
});
|
||||
|
||||
surface.elementModels.forEach(model => {
|
||||
createView(model);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.gfx.surface) {
|
||||
updateViewOnElementChange(this.gfx.surface);
|
||||
} else {
|
||||
this._disposable.add(
|
||||
onSurfaceAdded(this.std.store, surface => {
|
||||
if (surface) {
|
||||
updateViewOnElementChange(surface);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposable.dispose();
|
||||
this._viewMap.forEach(view => view.onDestroyed());
|
||||
this._viewMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
declare module '../controller.js' {
|
||||
interface GfxController {
|
||||
readonly view: ViewManager;
|
||||
}
|
||||
}
|
||||
221
blocksuite/framework/std/src/gfx/view/view.ts
Normal file
221
blocksuite/framework/std/src/gfx/view/view.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { Bound, IVec } from '@blocksuite/global/gfx';
|
||||
import type { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { PointerEventState } from '../../event/index.js';
|
||||
import type { EditorHost } from '../../view/index.js';
|
||||
import type {
|
||||
DragEndContext,
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
GfxViewTransformInterface,
|
||||
SelectedContext,
|
||||
} from '../element-transform/view-transform.js';
|
||||
import type { GfxController } from '../index.js';
|
||||
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
|
||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
|
||||
|
||||
export type EventsHandlerMap = {
|
||||
click: PointerEventState;
|
||||
dblclick: PointerEventState;
|
||||
pointerdown: PointerEventState;
|
||||
pointerenter: PointerEventState;
|
||||
pointerleave: PointerEventState;
|
||||
pointermove: PointerEventState;
|
||||
pointerup: PointerEventState;
|
||||
};
|
||||
|
||||
export type SupportedEvent = keyof EventsHandlerMap;
|
||||
|
||||
export const GfxElementModelViewExtIdentifier = createIdentifier<
|
||||
typeof GfxElementModelView
|
||||
>('GfxElementModelView');
|
||||
|
||||
export class GfxElementModelView<
|
||||
T extends GfxLocalElementModel | GfxPrimitiveElementModel =
|
||||
| GfxPrimitiveElementModel
|
||||
| GfxLocalElementModel,
|
||||
RendererContext = object,
|
||||
>
|
||||
implements GfxElementGeometry, Extension, GfxViewTransformInterface
|
||||
{
|
||||
static type: string;
|
||||
|
||||
private readonly _handlers = new Map<
|
||||
keyof EventsHandlerMap,
|
||||
((evt: any) => void)[]
|
||||
>();
|
||||
|
||||
private _isConnected = true;
|
||||
|
||||
protected disposable = new DisposableGroup();
|
||||
|
||||
readonly model: T;
|
||||
|
||||
get isConnected() {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
get rotate() {
|
||||
return this.model.rotate;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this.model.surface;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.model.type;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.gfx.std;
|
||||
}
|
||||
|
||||
constructor(
|
||||
model: T,
|
||||
readonly gfx: GfxController
|
||||
) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
static setup(di: Container): void {
|
||||
if (!this.type) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'The GfxElementModelView should have a static `type` property.'
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(GfxElementModelViewExtIdentifier(this.type), () => this);
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
return this.model.containsBound(bounds);
|
||||
}
|
||||
|
||||
dispatch<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
evt: EventsHandlerMap[K]
|
||||
) {
|
||||
this._handlers.get(event)?.forEach(callback => callback(evt));
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec) {
|
||||
return this.model.getLineIntersections(start, end);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec) {
|
||||
return this.model.getNearestPoint(point);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec) {
|
||||
return this.model.getRelativePointLocation(relativePoint);
|
||||
}
|
||||
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
_: PointTestOptions,
|
||||
__: EditorHost
|
||||
): boolean {
|
||||
return this.model.includesPoint(x, y, _, __);
|
||||
}
|
||||
|
||||
intersectsBound(bound: Bound): boolean {
|
||||
return (
|
||||
this.containsBound(bound) ||
|
||||
bound.points.some((point, i, points) =>
|
||||
this.getLineIntersections(point, points[(i + 1) % points.length])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
off<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
if (!this._handlers.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this._handlers.get(event)!;
|
||||
const index = callbacks.indexOf(callback);
|
||||
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
on<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
if (!this._handlers.has(event)) {
|
||||
this._handlers.set(event, []);
|
||||
}
|
||||
|
||||
this._handlers.get(event)!.push(callback);
|
||||
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
once<K extends keyof EventsHandlerMap>(
|
||||
event: K,
|
||||
callback: (evt: EventsHandlerMap[K]) => void
|
||||
) {
|
||||
const off = this.on(event, evt => {
|
||||
off();
|
||||
callback(evt);
|
||||
});
|
||||
|
||||
return off;
|
||||
}
|
||||
|
||||
onCreated() {}
|
||||
|
||||
onDragStart(_: DragStartContext) {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
this.model.stash('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnd(_: DragEndContext) {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
}
|
||||
|
||||
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
|
||||
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
|
||||
}
|
||||
|
||||
onSelected(context: SelectedContext) {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(this.model);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [this.model.id] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onResize = () => {};
|
||||
|
||||
onRotate = () => {};
|
||||
|
||||
/**
|
||||
* Called when the view is destroyed.
|
||||
* Override this method requires calling `super.onDestroyed()`.
|
||||
*/
|
||||
onDestroyed() {
|
||||
this._isConnected = false;
|
||||
this.disposable.dispose();
|
||||
this._handlers.clear();
|
||||
}
|
||||
|
||||
render(_: RendererContext) {}
|
||||
}
|
||||
216
blocksuite/framework/std/src/gfx/viewport-element.ts
Normal file
216
blocksuite/framework/std/src/gfx/viewport-element.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { PropTypes, requiredProperties } from '../view/decorators/required.js';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type EditorHost,
|
||||
ShadowlessElement,
|
||||
} from '../view/index.js';
|
||||
import type { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import { Viewport } from './viewport.js';
|
||||
|
||||
/**
|
||||
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
|
||||
*/
|
||||
export function requestThrottledConnectedFrame<
|
||||
T extends (...args: unknown[]) => void,
|
||||
>(func: T, element?: HTMLElement): T {
|
||||
let raqId: number | undefined = undefined;
|
||||
let latestArgs: unknown[] = [];
|
||||
|
||||
return ((...args: unknown[]) => {
|
||||
latestArgs = args;
|
||||
|
||||
if (raqId === undefined) {
|
||||
raqId = requestAnimationFrame(() => {
|
||||
raqId = undefined;
|
||||
|
||||
if (!element || element.isConnected) {
|
||||
func(...latestArgs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
|
||||
if (!view) return;
|
||||
|
||||
if (state === 'active') {
|
||||
view.style.visibility = 'visible';
|
||||
view.style.pointerEvents = 'auto';
|
||||
view.classList.remove('block-idle');
|
||||
view.classList.add('block-active');
|
||||
view.dataset.blockState = 'active';
|
||||
} else {
|
||||
view.style.visibility = 'hidden';
|
||||
view.style.pointerEvents = 'none';
|
||||
view.classList.remove('block-active');
|
||||
view.classList.add('block-idle');
|
||||
view.dataset.blockState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
@requiredProperties({
|
||||
viewport: PropTypes.instanceOf(Viewport),
|
||||
})
|
||||
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
gfx-viewport {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
contain: size layout style;
|
||||
display: block;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* CSS for idle blocks that are hidden but maintain layout */
|
||||
.block-idle {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
contain: size layout style;
|
||||
}
|
||||
|
||||
/* CSS for active blocks participating in viewport transformations */
|
||||
.block-active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _hideOutsideBlock = () => {
|
||||
if (!this.host) return;
|
||||
|
||||
const { host } = this;
|
||||
const modelsInViewport = this.getModelsInViewport();
|
||||
|
||||
modelsInViewport.forEach(model => {
|
||||
const view = host.std.view.getBlock(model.id);
|
||||
setBlockState(view, 'active');
|
||||
|
||||
if (this._lastVisibleModels?.has(model)) {
|
||||
this._lastVisibleModels!.delete(model);
|
||||
}
|
||||
});
|
||||
|
||||
this._lastVisibleModels?.forEach(model => {
|
||||
const view = host.std.view.getBlock(model.id);
|
||||
setBlockState(view, 'idle');
|
||||
});
|
||||
|
||||
this._lastVisibleModels = modelsInViewport;
|
||||
};
|
||||
|
||||
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
||||
|
||||
private readonly _pendingChildrenUpdates: {
|
||||
id: string;
|
||||
resolve: () => void;
|
||||
}[] = [];
|
||||
|
||||
private readonly _refreshViewport = requestThrottledConnectedFrame(() => {
|
||||
this._hideOutsideBlock();
|
||||
}, this);
|
||||
|
||||
private _updatingChildrenFlag = false;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const viewportUpdateCallback = () => {
|
||||
this._refreshViewport();
|
||||
};
|
||||
|
||||
if (!this.enableChildrenSchedule) {
|
||||
delete this.scheduleUpdateChildren;
|
||||
}
|
||||
|
||||
this._hideOutsideBlock();
|
||||
this.disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html``;
|
||||
}
|
||||
|
||||
scheduleUpdateChildren? = (id: string) => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
|
||||
this._pendingChildrenUpdates.push({ id, resolve });
|
||||
|
||||
if (!this._updatingChildrenFlag) {
|
||||
this._updatingChildrenFlag = true;
|
||||
const schedule = () => {
|
||||
if (this._pendingChildrenUpdates.length) {
|
||||
const childToUpdates = this._pendingChildrenUpdates.splice(
|
||||
0,
|
||||
this.maxConcurrentRenders
|
||||
);
|
||||
|
||||
childToUpdates.forEach(({ resolve }) => resolve());
|
||||
|
||||
if (this._pendingChildrenUpdates.length) {
|
||||
requestAnimationFrame(() => {
|
||||
this.isConnected && schedule();
|
||||
});
|
||||
} else {
|
||||
this._updatingChildrenFlag = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.isConnected && schedule();
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor getModelsInViewport: () => Set<GfxBlockElementModel> = () =>
|
||||
new Set();
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host: undefined | EditorHost;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxConcurrentRenders: number = 2;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableChildrenSchedule: boolean = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor viewport!: Viewport;
|
||||
|
||||
setBlocksActive(blockIds: string[]): void {
|
||||
if (!this.host) return;
|
||||
|
||||
blockIds.forEach(id => {
|
||||
const view = this.host?.std.view.getBlock(id);
|
||||
if (view) {
|
||||
setBlockState(view, 'active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setBlocksIdle(blockIds: string[]): void {
|
||||
if (!this.host) return;
|
||||
|
||||
blockIds.forEach(id => {
|
||||
const view = this.host?.std.view.getBlock(id);
|
||||
if (view) {
|
||||
setBlockState(view, 'idle');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
642
blocksuite/framework/std/src/gfx/viewport.ts
Normal file
642
blocksuite/framework/std/src/gfx/viewport.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import {
|
||||
Bound,
|
||||
clamp,
|
||||
type IPoint,
|
||||
type IVec,
|
||||
Vec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { BehaviorSubject, debounceTime, Subject } from 'rxjs';
|
||||
|
||||
import type { GfxViewportElement } from '.';
|
||||
|
||||
function cutoff(value: number, ref: number, sign: number) {
|
||||
if (sign > 0 && value > ref) return ref;
|
||||
if (sign < 0 && value < ref) return ref;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const ZOOM_MAX = 6.0;
|
||||
export const ZOOM_MIN = 0.1;
|
||||
export const ZOOM_STEP = 0.25;
|
||||
export const ZOOM_INITIAL = 1.0;
|
||||
|
||||
export const FIT_TO_SCREEN_PADDING = 100;
|
||||
|
||||
export interface ViewportRecord {
|
||||
left: number;
|
||||
top: number;
|
||||
viewportX: number;
|
||||
viewportY: number;
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
}
|
||||
|
||||
export function clientToModelCoord(
|
||||
viewport: ViewportRecord,
|
||||
clientCoord: [number, number]
|
||||
): IVec {
|
||||
const { left, top, viewportX, viewportY, zoom, viewScale } = viewport;
|
||||
|
||||
const [clientX, clientY] = clientCoord;
|
||||
const viewportInternalX = clientX - left;
|
||||
const viewportInternalY = clientY - top;
|
||||
const modelX = viewportX + viewportInternalX / zoom / viewScale;
|
||||
const modelY = viewportY + viewportInternalY / zoom / viewScale;
|
||||
return [modelX, modelY];
|
||||
}
|
||||
|
||||
export class Viewport {
|
||||
private _cachedBoundingClientRect: DOMRect | null = null;
|
||||
|
||||
private _cachedOffsetWidth: number | null = null;
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private readonly _resizeSubject = new Subject<{
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}>();
|
||||
|
||||
private _isResizing = false;
|
||||
private _initialTopLeft: IVec | null = null;
|
||||
|
||||
protected _center: IPoint = { x: 0, y: 0 };
|
||||
|
||||
protected _shell: HTMLElement | null = null;
|
||||
|
||||
protected _element: GfxViewportElement | null = null;
|
||||
|
||||
protected _height = 0;
|
||||
|
||||
protected _left = 0;
|
||||
|
||||
protected _locked = false;
|
||||
|
||||
protected _rafId: number | null = null;
|
||||
|
||||
protected _top = 0;
|
||||
|
||||
protected _width = 0;
|
||||
|
||||
protected _zoom: number = 1.0;
|
||||
|
||||
elementReady = new Subject<GfxViewportElement>();
|
||||
|
||||
sizeUpdated = new Subject<{
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}>();
|
||||
|
||||
viewportMoved = new Subject<IVec>();
|
||||
|
||||
viewportUpdated = new Subject<{
|
||||
zoom: number;
|
||||
center: IVec;
|
||||
}>();
|
||||
|
||||
zooming$ = new BehaviorSubject<boolean>(false);
|
||||
panning$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
ZOOM_MAX = ZOOM_MAX;
|
||||
|
||||
ZOOM_MIN = ZOOM_MIN;
|
||||
|
||||
private readonly _resetZooming = debounce(() => {
|
||||
this.zooming$.next(false);
|
||||
}, 200);
|
||||
|
||||
private readonly _resetPanning = debounce(() => {
|
||||
this.panning$.next(false);
|
||||
}, 200);
|
||||
|
||||
constructor() {
|
||||
const subscription = this.elementReady.subscribe(el => {
|
||||
this._element = el;
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
this._setupResizeObserver();
|
||||
}
|
||||
|
||||
private _setupResizeObserver() {
|
||||
this._resizeSubject
|
||||
.pipe(debounceTime(200))
|
||||
.subscribe(({ width, height, left, top }) => {
|
||||
if (!this._shell || !this._initialTopLeft) return;
|
||||
this._completeResize(width, height, left, top);
|
||||
});
|
||||
}
|
||||
|
||||
private _completeResize(
|
||||
width: number,
|
||||
height: number,
|
||||
left: number,
|
||||
top: number
|
||||
) {
|
||||
if (!this._initialTopLeft) return;
|
||||
|
||||
const [initialTopLeftX, initialTopLeftY] = this._initialTopLeft;
|
||||
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
|
||||
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
|
||||
|
||||
this.setCenter(newCenterX, newCenterY);
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._left = left;
|
||||
this._top = top;
|
||||
this._isResizing = false;
|
||||
this._initialTopLeft = null;
|
||||
|
||||
this.sizeUpdated.next({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
private _forceCompleteResize() {
|
||||
if (this._isResizing && this._shell) {
|
||||
const { width, height, left, top } = this.boundingClientRect;
|
||||
this._completeResize(width, height, left, top);
|
||||
}
|
||||
}
|
||||
|
||||
get boundingClientRect() {
|
||||
if (!this._shell) return new DOMRect(0, 0, 0, 0);
|
||||
if (!this._cachedBoundingClientRect) {
|
||||
this._cachedBoundingClientRect = this._shell.getBoundingClientRect();
|
||||
}
|
||||
return this._cachedBoundingClientRect;
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
get center() {
|
||||
return this._center;
|
||||
}
|
||||
|
||||
get centerX() {
|
||||
return this._center.x;
|
||||
}
|
||||
|
||||
get centerY() {
|
||||
return this._center.y;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.boundingClientRect.height;
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this._left;
|
||||
}
|
||||
|
||||
// Does not allow the user to move and zoom the canvas in copilot tool
|
||||
get locked() {
|
||||
return this._locked;
|
||||
}
|
||||
|
||||
set locked(locked: boolean) {
|
||||
this._locked = locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note this is different from the zoom property.
|
||||
* The editor itself may be scaled by outer container which is common in nested editor scenarios.
|
||||
* This property is used to calculate the scale of the editor.
|
||||
*/
|
||||
get viewScale() {
|
||||
if (!this._shell || this._cachedOffsetWidth === null) return 1;
|
||||
return this.boundingClientRect.width / this._cachedOffsetWidth;
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this._top;
|
||||
}
|
||||
|
||||
get translateX() {
|
||||
return -this.viewportX * this.zoom;
|
||||
}
|
||||
|
||||
get translateY() {
|
||||
return -this.viewportY * this.zoom;
|
||||
}
|
||||
|
||||
get viewportBounds() {
|
||||
const { viewportMinXY, viewportMaxXY } = this;
|
||||
|
||||
return Bound.from({
|
||||
...viewportMinXY,
|
||||
w: viewportMaxXY.x - viewportMinXY.x,
|
||||
h: viewportMaxXY.y - viewportMinXY.y,
|
||||
});
|
||||
}
|
||||
|
||||
get viewportMaxXY() {
|
||||
const { centerX, centerY, width, height, zoom } = this;
|
||||
return {
|
||||
x: centerX + width / 2 / zoom,
|
||||
y: centerY + height / 2 / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
get viewportMinXY() {
|
||||
const { centerX, centerY, width, height, zoom } = this;
|
||||
return {
|
||||
x: centerX - width / 2 / zoom,
|
||||
y: centerY - height / 2 / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
get viewportX() {
|
||||
const { centerX, width, zoom } = this;
|
||||
return centerX - width / 2 / zoom;
|
||||
}
|
||||
|
||||
get viewportY() {
|
||||
const { centerY, height, zoom } = this;
|
||||
return centerY - height / 2 / zoom;
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.boundingClientRect.width;
|
||||
}
|
||||
|
||||
get zoom() {
|
||||
return this._zoom;
|
||||
}
|
||||
|
||||
applyDeltaCenter(deltaX: number, deltaY: number) {
|
||||
this.setCenter(this.centerX + deltaX, this.centerY + deltaY);
|
||||
}
|
||||
|
||||
clearViewportElement() {
|
||||
if (this._resizeObserver && this._shell) {
|
||||
this._resizeObserver.unobserve(this._shell);
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
this._resizeObserver = null;
|
||||
this._shell = null;
|
||||
this._cachedBoundingClientRect = null;
|
||||
this._cachedOffsetWidth = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clearViewportElement();
|
||||
this.sizeUpdated.complete();
|
||||
this.viewportMoved.complete();
|
||||
this.viewportUpdated.complete();
|
||||
this._resizeSubject.complete();
|
||||
this.zooming$.complete();
|
||||
this.panning$.complete();
|
||||
}
|
||||
|
||||
getFitToScreenData(
|
||||
bounds?: Bound | null,
|
||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||
maxZoom = ZOOM_MAX,
|
||||
fitToScreenPadding = 100
|
||||
) {
|
||||
let { centerX, centerY, zoom } = this;
|
||||
|
||||
if (!bounds) {
|
||||
return { zoom, centerX, centerY };
|
||||
}
|
||||
|
||||
const { x, y, w, h } = bounds;
|
||||
const [pt, pr, pb, pl] = padding;
|
||||
const { width, height } = this;
|
||||
|
||||
zoom = Math.min(
|
||||
(width - fitToScreenPadding - (pr + pl)) / w,
|
||||
(height - fitToScreenPadding - (pt + pb)) / h
|
||||
);
|
||||
zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX));
|
||||
|
||||
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
|
||||
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
|
||||
|
||||
return { zoom, centerX, centerY };
|
||||
}
|
||||
|
||||
isInViewport(bound: Bound) {
|
||||
const viewportBounds = Bound.from(this.viewportBounds);
|
||||
return (
|
||||
viewportBounds.contains(bound) ||
|
||||
viewportBounds.isIntersectWithBound(bound)
|
||||
);
|
||||
}
|
||||
|
||||
onResize() {
|
||||
if (!this._shell) return;
|
||||
|
||||
if (!this._isResizing) {
|
||||
this._isResizing = true;
|
||||
this._initialTopLeft = this.toModelCoord(0, 0);
|
||||
}
|
||||
|
||||
const { left, top, width, height } = this.boundingClientRect;
|
||||
this._cachedOffsetWidth = this._shell.offsetWidth;
|
||||
|
||||
this._left = left;
|
||||
this._top = top;
|
||||
this._resizeSubject.next({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the center of the viewport.
|
||||
* @param centerX The new x coordinate of the center of the viewport.
|
||||
* @param centerY The new y coordinate of the center of the viewport.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setCenter(centerX: number, centerY: number, forceUpdate = false) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
}
|
||||
|
||||
this._center.x = centerX;
|
||||
this._center.y = centerY;
|
||||
this.panning$.next(true);
|
||||
this.viewportUpdated.next({
|
||||
zoom: this.zoom,
|
||||
center: Vec.toVec(this.center) as IVec,
|
||||
});
|
||||
this._resetPanning();
|
||||
}
|
||||
|
||||
setRect(left: number, top: number, width: number, height: number) {
|
||||
if (this._isResizing) {
|
||||
this._left = left;
|
||||
this._top = top;
|
||||
return;
|
||||
}
|
||||
|
||||
this._left = left;
|
||||
this._top = top;
|
||||
this.sizeUpdated.next({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the viewport to the new zoom and center.
|
||||
* @param newZoom The new zoom value.
|
||||
* @param newCenter The new center of the viewport.
|
||||
* @param smooth Whether to animate the zooming and panning.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setViewport(
|
||||
newZoom: number,
|
||||
newCenter = Vec.toVec(this.center),
|
||||
smooth = false,
|
||||
forceUpdate = smooth
|
||||
) {
|
||||
// Force complete any pending resize operations if forceUpdate is true
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
}
|
||||
|
||||
const preZoom = this._zoom;
|
||||
if (smooth) {
|
||||
const cofficient = preZoom / newZoom;
|
||||
if (cofficient === 1) {
|
||||
this.smoothTranslate(newCenter[0], newCenter[1]);
|
||||
} else {
|
||||
const center = [this.centerX, this.centerY] as IVec;
|
||||
const focusPoint = Vec.mul(
|
||||
Vec.sub(newCenter, Vec.mul(center, cofficient)),
|
||||
1 / (1 - cofficient)
|
||||
);
|
||||
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
|
||||
}
|
||||
} else {
|
||||
this._center.x = newCenter[0];
|
||||
this._center.y = newCenter[1];
|
||||
this.setZoom(newZoom, undefined, false, forceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the viewport to fit the bound with padding.
|
||||
* @param bound The bound will be zoomed to fit the viewport.
|
||||
* @param padding The padding will be applied to the bound after zooming, default is [0, 0, 0, 0],
|
||||
* the value may be reduced if there is not enough space for the padding.
|
||||
* Use decimal less than 1 to represent percentage padding. e.g. [0.1, 0.1, 0.1, 0.1] means 10% padding.
|
||||
* @param smooth whether to animate the zooming
|
||||
* @param forceUpdate whether to force complete any pending resize operations before setting the viewport
|
||||
*/
|
||||
setViewportByBound(
|
||||
bound: Bound,
|
||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||
smooth = false,
|
||||
forceUpdate = smooth
|
||||
) {
|
||||
let [pt, pr, pb, pl] = padding;
|
||||
|
||||
// Convert percentage padding to absolute values if they are between 0 and 1
|
||||
if (pt > 0 && pt < 1) pt *= this.height;
|
||||
if (pr > 0 && pr < 1) pr *= this.width;
|
||||
if (pb > 0 && pb < 1) pb *= this.height;
|
||||
if (pl > 0 && pl < 1) pl *= this.width;
|
||||
|
||||
// Calculate zoom
|
||||
let zoom = Math.min(
|
||||
(this.width - (pr + pl)) / bound.w,
|
||||
(this.height - (pt + pb)) / bound.h
|
||||
);
|
||||
|
||||
// Adjust padding if space is not enough
|
||||
if (zoom < this.ZOOM_MIN) {
|
||||
zoom = this.ZOOM_MIN;
|
||||
const totalPaddingWidth = this.width - bound.w * zoom;
|
||||
const totalPaddingHeight = this.height - bound.h * zoom;
|
||||
pr = pl = Math.max(totalPaddingWidth / 2, 1);
|
||||
pt = pb = Math.max(totalPaddingHeight / 2, 1);
|
||||
}
|
||||
|
||||
// Ensure zoom does not exceed ZOOM_MAX
|
||||
if (zoom > this.ZOOM_MAX) {
|
||||
zoom = this.ZOOM_MAX;
|
||||
}
|
||||
|
||||
const center = [
|
||||
bound.x + (bound.w + pr / zoom) / 2 - pl / zoom / 2,
|
||||
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
|
||||
] as IVec;
|
||||
|
||||
this.setViewport(zoom, center, smooth, forceUpdate);
|
||||
}
|
||||
|
||||
/** This is the outer container of the viewport, which is the host of the viewport element */
|
||||
setShellElement(el: HTMLElement) {
|
||||
this._shell = el;
|
||||
this._cachedBoundingClientRect = el.getBoundingClientRect();
|
||||
this._cachedOffsetWidth = el.offsetWidth;
|
||||
|
||||
const { left, top, width, height } = this._cachedBoundingClientRect;
|
||||
this.setRect(left, top, width, height);
|
||||
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
this._cachedBoundingClientRect = null;
|
||||
this._cachedOffsetWidth = null;
|
||||
this.onResize();
|
||||
});
|
||||
this._resizeObserver.observe(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the viewport to the new zoom.
|
||||
* @param zoom The new zoom value.
|
||||
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
|
||||
* @param wheel Whether the zoom is caused by wheel event.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setZoom(
|
||||
zoom: number,
|
||||
focusPoint?: IPoint,
|
||||
wheel = false,
|
||||
forceUpdate = false
|
||||
) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
}
|
||||
|
||||
const prevZoom = this.zoom;
|
||||
focusPoint = (focusPoint ?? this._center) as IPoint;
|
||||
this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX);
|
||||
const newZoom = this.zoom;
|
||||
|
||||
const offset = Vec.sub(Vec.toVec(this.center), Vec.toVec(focusPoint));
|
||||
const newCenter = Vec.add(
|
||||
Vec.toVec(focusPoint),
|
||||
Vec.mul(offset, prevZoom / newZoom)
|
||||
);
|
||||
if (wheel) {
|
||||
this.zooming$.next(true);
|
||||
}
|
||||
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
|
||||
this.viewportUpdated.next({
|
||||
zoom: this.zoom,
|
||||
center: Vec.toVec(this.center) as IVec,
|
||||
});
|
||||
this._resetZooming();
|
||||
}
|
||||
|
||||
smoothTranslate(x: number, y: number, numSteps = 10) {
|
||||
const { center } = this;
|
||||
const delta = { x: x - center.x, y: y - center.y };
|
||||
const innerSmoothTranslate = () => {
|
||||
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||
this._rafId = requestAnimationFrame(() => {
|
||||
const step = { x: delta.x / numSteps, y: delta.y / numSteps };
|
||||
const nextCenter = {
|
||||
x: this.centerX + step.x,
|
||||
y: this.centerY + step.y,
|
||||
};
|
||||
const signX = delta.x > 0 ? 1 : -1;
|
||||
const signY = delta.y > 0 ? 1 : -1;
|
||||
nextCenter.x = cutoff(nextCenter.x, x, signX);
|
||||
nextCenter.y = cutoff(nextCenter.y, y, signY);
|
||||
this.setCenter(nextCenter.x, nextCenter.y, true);
|
||||
|
||||
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
|
||||
});
|
||||
};
|
||||
innerSmoothTranslate();
|
||||
}
|
||||
|
||||
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
|
||||
const delta = zoom - this.zoom;
|
||||
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||
|
||||
const innerSmoothZoom = () => {
|
||||
this._rafId = requestAnimationFrame(() => {
|
||||
const sign = delta > 0 ? 1 : -1;
|
||||
const step = delta / numSteps;
|
||||
const nextZoom = cutoff(this.zoom + step, zoom, sign);
|
||||
|
||||
this.setZoom(nextZoom, focusPoint, undefined, true);
|
||||
|
||||
if (nextZoom != zoom) innerSmoothZoom();
|
||||
});
|
||||
};
|
||||
innerSmoothZoom();
|
||||
}
|
||||
|
||||
toModelBound(bound: Bound) {
|
||||
const { w, h } = bound;
|
||||
const [x, y] = this.toModelCoord(bound.x, bound.y);
|
||||
|
||||
return new Bound(x, y, w / this.zoom, h / this.zoom);
|
||||
}
|
||||
|
||||
toModelCoord(viewX: number, viewY: number): IVec {
|
||||
const { viewportX, viewportY, zoom, viewScale } = this;
|
||||
return [
|
||||
viewportX + viewX / zoom / viewScale,
|
||||
viewportY + viewY / zoom / viewScale,
|
||||
];
|
||||
}
|
||||
|
||||
toModelCoordFromClientCoord([x, y]: IVec): IVec {
|
||||
return clientToModelCoord(this, [x, y]);
|
||||
}
|
||||
|
||||
toViewBound(bound: Bound) {
|
||||
const { w, h } = bound;
|
||||
const [x, y] = this.toViewCoord(bound.x, bound.y);
|
||||
|
||||
return new Bound(x, y, w * this.zoom, h * this.zoom);
|
||||
}
|
||||
|
||||
toViewCoord(modelX: number, modelY: number): IVec {
|
||||
const { viewportX, viewportY, zoom, viewScale } = this;
|
||||
return [
|
||||
(modelX - viewportX) * zoom * viewScale,
|
||||
(modelY - viewportY) * zoom * viewScale,
|
||||
];
|
||||
}
|
||||
|
||||
toViewCoordFromClientCoord([x, y]: IVec): IVec {
|
||||
const { left, top } = this;
|
||||
return [x - left, y - top];
|
||||
}
|
||||
|
||||
serializeRecord() {
|
||||
return JSON.stringify({
|
||||
left: this.left,
|
||||
top: this.top,
|
||||
viewportX: this.viewportX,
|
||||
viewportY: this.viewportY,
|
||||
zoom: this.zoom,
|
||||
viewScale: this.viewScale,
|
||||
});
|
||||
}
|
||||
|
||||
deserializeRecord(record?: string) {
|
||||
try {
|
||||
const result = JSON.parse(record || '{}') as ViewportRecord;
|
||||
if (!('zoom' in result)) return null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to deserialize viewport record:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
blocksuite/framework/std/src/identifier.ts
Normal file
34
blocksuite/framework/std/src/identifier.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import type { Command } from './command/index.js';
|
||||
import type { EventOptions, UIEventHandler } from './event/index.js';
|
||||
import type { BlockService, LifeCycleWatcher } from './extension/index.js';
|
||||
import type { BlockStdScope } from './scope/index.js';
|
||||
import type { BlockViewType, WidgetViewType } from './spec/type.js';
|
||||
|
||||
export const BlockServiceIdentifier =
|
||||
createIdentifier<BlockService>('BlockService');
|
||||
|
||||
export const BlockFlavourIdentifier = createIdentifier<{ flavour: string }>(
|
||||
'BlockFlavour'
|
||||
);
|
||||
|
||||
export const CommandIdentifier = createIdentifier<Command>('Commands');
|
||||
|
||||
export const ConfigIdentifier =
|
||||
createIdentifier<Record<string, unknown>>('Config');
|
||||
|
||||
export const BlockViewIdentifier = createIdentifier<BlockViewType>('BlockView');
|
||||
|
||||
export const WidgetViewIdentifier =
|
||||
createIdentifier<WidgetViewType>('WidgetView');
|
||||
|
||||
export const LifeCycleWatcherIdentifier =
|
||||
createIdentifier<LifeCycleWatcher>('LifeCycleWatcher');
|
||||
|
||||
export const StdIdentifier = createIdentifier<BlockStdScope>('Std');
|
||||
|
||||
export const KeymapIdentifier = createIdentifier<{
|
||||
getter: (std: BlockStdScope) => Record<string, UIEventHandler>;
|
||||
options?: EventOptions;
|
||||
}>('Keymap');
|
||||
9
blocksuite/framework/std/src/index.ts
Normal file
9
blocksuite/framework/std/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './clipboard/index.js';
|
||||
export * from './command/index.js';
|
||||
export * from './event/index.js';
|
||||
export * from './extension/index.js';
|
||||
export * from './identifier.js';
|
||||
export * from './scope/index.js';
|
||||
export * from './selection/index.js';
|
||||
export * from './spec/index.js';
|
||||
export * from './view/index.js';
|
||||
12
blocksuite/framework/std/src/inline/components/embed-gap.ts
Normal file
12
blocksuite/framework/std/src/inline/components/embed-gap.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export const EmbedGap = html`<span
|
||||
data-v-embed-gap="true"
|
||||
style=${styleMap({
|
||||
userSelect: 'text',
|
||||
padding: '0 0.5px',
|
||||
outline: 'none',
|
||||
})}
|
||||
><v-text></v-text
|
||||
></span>`;
|
||||
3
blocksuite/framework/std/src/inline/components/index.ts
Normal file
3
blocksuite/framework/std/src/inline/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './v-element.js';
|
||||
export * from './v-line.js';
|
||||
export * from './v-text.js';
|
||||
113
blocksuite/framework/std/src/inline/components/v-element.ts
Normal file
113
blocksuite/framework/std/src/inline/components/v-element.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import { isInlineRangeIntersect } from '../utils/inline-range.js';
|
||||
|
||||
export class VElement<
|
||||
T extends BaseTextAttributes = BaseTextAttributes,
|
||||
> extends SignalWatcher(LitElement) {
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly selected = signal(false);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const inlineRange = this.inlineEditor.inlineRange$.value;
|
||||
this.selected.value =
|
||||
!!inlineRange &&
|
||||
isInlineRangeIntersect(inlineRange, {
|
||||
index: this.startOffset,
|
||||
length: this.endOffset - this.startOffset,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
const span = this.querySelector('[data-v-element="true"]') as HTMLElement;
|
||||
const el = span.firstElementChild as LitElement;
|
||||
await el.updateComplete;
|
||||
const vTexts = Array.from(this.querySelectorAll('v-text'));
|
||||
await Promise.all(vTexts.map(vText => vText.updateComplete));
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const attributeRenderer = inlineEditor.attributeService.attributeRenderer;
|
||||
const renderProps: Parameters<typeof attributeRenderer>[0] = {
|
||||
delta: this.delta,
|
||||
selected: this.selected.value,
|
||||
startOffset: this.startOffset,
|
||||
endOffset: this.endOffset,
|
||||
lineIndex: this.lineIndex,
|
||||
editor: inlineEditor,
|
||||
};
|
||||
|
||||
const isEmbed = inlineEditor.isEmbed(this.delta);
|
||||
if (isEmbed) {
|
||||
if (this.delta.insert.length !== 1) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
`The length of embed node should only be 1.
|
||||
This seems to be an internal issue with inline editor.
|
||||
Please go to https://github.com/toeverything/blocksuite/issues
|
||||
to report it.`
|
||||
);
|
||||
}
|
||||
|
||||
return html`<span
|
||||
data-v-embed="true"
|
||||
data-v-element="true"
|
||||
contenteditable="false"
|
||||
style=${styleMap({ userSelect: 'none' })}
|
||||
>${attributeRenderer(renderProps)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the unexpected space
|
||||
return html`<span data-v-element="true"
|
||||
>${attributeRenderer(renderProps)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<T> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: InlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor lineIndex!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startOffset!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-element': VElement;
|
||||
}
|
||||
}
|
||||
154
blocksuite/framework/std/src/inline/components/v-line.ts
Normal file
154
blocksuite/framework/std/src/inline/components/v-line.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { html, LitElement, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { InlineRootElement } from '../inline-editor.js';
|
||||
import { EmbedGap } from './embed-gap.js';
|
||||
|
||||
export class VLine extends LitElement {
|
||||
get inlineEditor() {
|
||||
const rootElement = this.closest(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
) as InlineRootElement;
|
||||
if (!rootElement) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'v-line must be inside a v-root'
|
||||
);
|
||||
}
|
||||
const inlineEditor = rootElement.inlineEditor;
|
||||
if (!inlineEditor) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'v-line must be inside a v-root with inline-editor'
|
||||
);
|
||||
}
|
||||
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
get vElements() {
|
||||
return Array.from(this.querySelectorAll('v-element'));
|
||||
}
|
||||
|
||||
get vTextContent() {
|
||||
return this.vElements.reduce((acc, el) => acc + el.delta.insert, '');
|
||||
}
|
||||
|
||||
get vTextLength() {
|
||||
return this.vElements.reduce((acc, el) => acc + el.delta.insert.length, 0);
|
||||
}
|
||||
|
||||
// you should use vElements.length or vTextLength because v-element corresponds to the actual delta
|
||||
get vTexts() {
|
||||
return Array.from(this.querySelectorAll('v-text'));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override firstUpdated(): void {
|
||||
this.style.display = 'block';
|
||||
|
||||
this.addEventListener('mousedown', e => {
|
||||
if (e.detail >= 2 && this.startOffset === this.endOffset) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.detail >= 3) {
|
||||
e.preventDefault();
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: this.startOffset,
|
||||
length: this.endOffset - this.startOffset,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// vTexts.length > 0 does not mean the line is not empty,
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await Promise.all(this.vElements.map(el => el.updateComplete));
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
if (this.inlineEditor.vLineRenderer) {
|
||||
return this.inlineEditor.vLineRenderer(this);
|
||||
}
|
||||
return this.renderVElements();
|
||||
}
|
||||
|
||||
renderVElements() {
|
||||
if (this.elements.length === 0) {
|
||||
// don't use v-element because it not correspond to the actual delta
|
||||
return html`<div><v-text .str=${ZERO_WIDTH_SPACE}></v-text></div>`;
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const renderElements = this.elements.flatMap(([template, delta], index) => {
|
||||
if (inlineEditor.isEmbed(delta)) {
|
||||
if (delta.insert.length !== 1) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
`The length of embed node should only be 1.
|
||||
This seems to be an internal issue with inline editor.
|
||||
Please go to https://github.com/toeverything/blocksuite/issues
|
||||
to report it.`
|
||||
);
|
||||
}
|
||||
// we add `EmbedGap` to make cursor can be placed between embed elements
|
||||
if (index === 0) {
|
||||
const nextDelta = this.elements[index + 1]?.[1];
|
||||
if (!nextDelta || inlineEditor.isEmbed(nextDelta)) {
|
||||
return [EmbedGap, template, EmbedGap];
|
||||
} else {
|
||||
return [EmbedGap, template];
|
||||
}
|
||||
} else {
|
||||
const nextDelta = this.elements[index + 1]?.[1];
|
||||
if (!nextDelta || inlineEditor.isEmbed(nextDelta)) {
|
||||
return [template, EmbedGap];
|
||||
} else {
|
||||
return [template];
|
||||
}
|
||||
}
|
||||
}
|
||||
return template;
|
||||
});
|
||||
|
||||
// prettier will generate \n and cause unexpected space and line break
|
||||
// prettier-ignore
|
||||
return html`<div style=${styleMap({
|
||||
// this padding is used to make cursor can be placed at the
|
||||
// start and end of the line when the first and last element is embed element
|
||||
padding: '0 0.5px',
|
||||
display: 'inline-block',
|
||||
})}>${renderElements}</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: [TemplateResult<1>, DeltaInsert][] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor index!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startOffset!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-line': VLine;
|
||||
}
|
||||
}
|
||||
34
blocksuite/framework/std/src/inline/components/v-text.ts
Normal file
34
blocksuite/framework/std/src/inline/components/v-text.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
|
||||
export class VText extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override render() {
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the sync problem about the cursor position
|
||||
return html`<span
|
||||
style=${styleMap({
|
||||
'word-break': 'break-word',
|
||||
'text-wrap': 'wrap',
|
||||
'white-space-collapse': 'break-spaces',
|
||||
})}
|
||||
data-v-text="true"
|
||||
>${this.str}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor str: string = ZERO_WIDTH_SPACE;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'v-text': VText;
|
||||
}
|
||||
}
|
||||
7
blocksuite/framework/std/src/inline/consts.ts
Normal file
7
blocksuite/framework/std/src/inline/consts.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IS_SAFARI } from '@blocksuite/global/env';
|
||||
|
||||
export const ZERO_WIDTH_SPACE = IS_SAFARI ? '\u200C' : '\u200B';
|
||||
// see https://en.wikipedia.org/wiki/Zero-width_non-joiner
|
||||
export const ZERO_WIDTH_NON_JOINER = '\u200C';
|
||||
|
||||
export const INLINE_ROOT_ATTR = 'data-v-root';
|
||||
4
blocksuite/framework/std/src/inline/extensions/index.ts
Normal file
4
blocksuite/framework/std/src/inline/extensions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './inline-manager';
|
||||
export * from './inline-spec';
|
||||
export * from './markdown-matcher';
|
||||
export * from './type';
|
||||
117
blocksuite/framework/std/src/inline/extensions/inline-manager.ts
Normal file
117
blocksuite/framework/std/src/inline/extensions/inline-manager.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import {
|
||||
type BaseTextAttributes,
|
||||
baseTextAttributes,
|
||||
type DeltaInsert,
|
||||
type ExtensionType,
|
||||
} from '@blocksuite/store';
|
||||
import { z, type ZodObject, type ZodTypeAny } from 'zod';
|
||||
|
||||
import { StdIdentifier } from '../../identifier.js';
|
||||
import type { BlockStdScope } from '../../scope/index.js';
|
||||
import type { AttributeRenderer } from '../types.js';
|
||||
import { getDefaultAttributeRenderer } from '../utils/attribute-renderer.js';
|
||||
import { MarkdownMatcherIdentifier } from './markdown-matcher.js';
|
||||
import type { InlineMarkdownMatch, InlineSpecs } from './type.js';
|
||||
|
||||
export class InlineManager<TextAttributes extends BaseTextAttributes> {
|
||||
embedChecker = (delta: DeltaInsert<TextAttributes>) => {
|
||||
for (const spec of this.specs) {
|
||||
if (spec.embed && spec.match(delta)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
getRenderer = (): AttributeRenderer<TextAttributes> => {
|
||||
const defaultRenderer = getDefaultAttributeRenderer<TextAttributes>();
|
||||
|
||||
const renderer: AttributeRenderer<TextAttributes> = props => {
|
||||
// Priority increases from front to back
|
||||
for (const spec of this.specs.toReversed()) {
|
||||
if (spec.match(props.delta)) {
|
||||
return spec.renderer(props);
|
||||
}
|
||||
}
|
||||
return defaultRenderer(props);
|
||||
};
|
||||
return renderer;
|
||||
};
|
||||
|
||||
getSchema = (): ZodObject<Record<keyof TextAttributes, ZodTypeAny>> => {
|
||||
const defaultSchema = baseTextAttributes as unknown as ZodObject<
|
||||
Record<keyof TextAttributes, ZodTypeAny>
|
||||
>;
|
||||
|
||||
const schema: ZodObject<Record<keyof TextAttributes, ZodTypeAny>> =
|
||||
this.specs.reduce((acc, cur) => {
|
||||
const currentSchema = z.object({
|
||||
[cur.name]: cur.schema,
|
||||
}) as ZodObject<Record<keyof TextAttributes, ZodTypeAny>>;
|
||||
return acc.merge(currentSchema) as ZodObject<
|
||||
Record<keyof TextAttributes, ZodTypeAny>
|
||||
>;
|
||||
}, defaultSchema);
|
||||
return schema;
|
||||
};
|
||||
|
||||
get markdownMatches(): InlineMarkdownMatch<TextAttributes>[] {
|
||||
if (!this.enableMarkdown) {
|
||||
return [];
|
||||
}
|
||||
const matches = Array.from(
|
||||
this.std.provider.getAll(MarkdownMatcherIdentifier).values()
|
||||
);
|
||||
return matches as InlineMarkdownMatch<TextAttributes>[];
|
||||
}
|
||||
|
||||
readonly specs: Array<InlineSpecs<TextAttributes>>;
|
||||
|
||||
constructor(
|
||||
readonly std: BlockStdScope,
|
||||
readonly enableMarkdown: boolean,
|
||||
...specs: Array<InlineSpecs<TextAttributes>>
|
||||
) {
|
||||
this.specs = specs;
|
||||
}
|
||||
}
|
||||
|
||||
export type InlineManagerExtensionConfig<
|
||||
TextAttributes extends BaseTextAttributes,
|
||||
> = {
|
||||
id: string;
|
||||
enableMarkdown?: boolean;
|
||||
specs: ServiceIdentifier<InlineSpecs<TextAttributes>>[];
|
||||
};
|
||||
|
||||
const InlineManagerIdentifier = createIdentifier<unknown>(
|
||||
'AffineInlineManager'
|
||||
);
|
||||
|
||||
export function InlineManagerExtension<
|
||||
TextAttributes extends BaseTextAttributes,
|
||||
>({
|
||||
id,
|
||||
enableMarkdown = true,
|
||||
specs,
|
||||
}: InlineManagerExtensionConfig<TextAttributes>): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineManager<TextAttributes>>;
|
||||
} {
|
||||
const identifier = InlineManagerIdentifier<InlineManager<TextAttributes>>(id);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, provider => {
|
||||
return new InlineManager(
|
||||
provider.get(StdIdentifier),
|
||||
enableMarkdown,
|
||||
...specs.map(spec => provider.get(spec))
|
||||
);
|
||||
});
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
type ServiceProvider,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { BaseTextAttributes, ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { InlineSpecs } from './type.js';
|
||||
|
||||
export const InlineSpecIdentifier =
|
||||
createIdentifier<unknown>('AffineInlineSpec');
|
||||
|
||||
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
|
||||
name: string,
|
||||
getSpec: (provider: ServiceProvider) => InlineSpecs<TextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
|
||||
};
|
||||
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
|
||||
spec: InlineSpecs<TextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
|
||||
};
|
||||
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
|
||||
nameOrSpec: string | InlineSpecs<TextAttributes>,
|
||||
getSpec?: (provider: ServiceProvider) => InlineSpecs<TextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
|
||||
} {
|
||||
if (typeof nameOrSpec === 'string') {
|
||||
const identifier =
|
||||
InlineSpecIdentifier<InlineSpecs<TextAttributes>>(nameOrSpec);
|
||||
return {
|
||||
identifier,
|
||||
setup: di => {
|
||||
di.addImpl(identifier, provider => getSpec!(provider));
|
||||
},
|
||||
};
|
||||
}
|
||||
const identifier = InlineSpecIdentifier<InlineSpecs<TextAttributes>>(
|
||||
nameOrSpec.name as string
|
||||
);
|
||||
return {
|
||||
identifier,
|
||||
setup: di => {
|
||||
di.addImpl(identifier, nameOrSpec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { BaseTextAttributes, ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { InlineMarkdownMatch } from './type.js';
|
||||
|
||||
export const MarkdownMatcherIdentifier = createIdentifier<unknown>(
|
||||
'AffineMarkdownMatcher'
|
||||
);
|
||||
|
||||
export function InlineMarkdownExtension<
|
||||
TextAttributes extends BaseTextAttributes,
|
||||
>(
|
||||
matcher: InlineMarkdownMatch<TextAttributes>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<InlineMarkdownMatch<TextAttributes>>;
|
||||
} {
|
||||
const identifier = MarkdownMatcherIdentifier<
|
||||
InlineMarkdownMatch<TextAttributes>
|
||||
>(matcher.name);
|
||||
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => ({ ...matcher }));
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
37
blocksuite/framework/std/src/inline/extensions/type.ts
Normal file
37
blocksuite/framework/std/src/inline/extensions/type.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
AttributeRenderer,
|
||||
InlineEditor,
|
||||
InlineRange,
|
||||
} from '@blocksuite/std/inline';
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
import type * as Y from 'yjs';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
export type InlineSpecs<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = {
|
||||
name: keyof TextAttributes | string;
|
||||
schema: ZodTypeAny;
|
||||
match: (delta: DeltaInsert<TextAttributes>) => boolean;
|
||||
renderer: AttributeRenderer<TextAttributes>;
|
||||
embed?: boolean;
|
||||
};
|
||||
|
||||
export type InlineMarkdownMatchAction<
|
||||
// @ts-expect-error We allow to covariance for AffineTextAttributes
|
||||
in AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = (props: {
|
||||
inlineEditor: InlineEditor<AffineTextAttributes>;
|
||||
prefixText: string;
|
||||
inlineRange: InlineRange;
|
||||
pattern: RegExp;
|
||||
undoManager: Y.UndoManager;
|
||||
}) => void;
|
||||
|
||||
export type InlineMarkdownMatch<
|
||||
AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = {
|
||||
name: string;
|
||||
pattern: RegExp;
|
||||
action: InlineMarkdownMatchAction<AffineTextAttributes>;
|
||||
};
|
||||
8
blocksuite/framework/std/src/inline/index.ts
Normal file
8
blocksuite/framework/std/src/inline/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './components';
|
||||
export * from './consts';
|
||||
export * from './extensions';
|
||||
export * from './inline-editor';
|
||||
export * from './range';
|
||||
export * from './services';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
296
blocksuite/framework/std/src/inline/inline-editor.ts
Normal file
296
blocksuite/framework/std/src/inline/inline-editor.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import { nothing, render, type TemplateResult } from 'lit';
|
||||
import { Subject } from 'rxjs';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { VLine } from './components/v-line.js';
|
||||
import { INLINE_ROOT_ATTR } from './consts.js';
|
||||
import { InlineHookService } from './services/hook.js';
|
||||
import {
|
||||
AttributeService,
|
||||
DeltaService,
|
||||
EventService,
|
||||
RangeService,
|
||||
} from './services/index.js';
|
||||
import { RenderService } from './services/render.js';
|
||||
import { InlineTextService } from './services/text.js';
|
||||
import type { InlineRange } from './types.js';
|
||||
import { nativePointToTextPoint, textPointToDomPoint } from './utils/index.js';
|
||||
import { getTextNodesFromElement } from './utils/text.js';
|
||||
|
||||
export type InlineRootElement<
|
||||
T extends BaseTextAttributes = BaseTextAttributes,
|
||||
> = HTMLElement & {
|
||||
inlineEditor: InlineEditor<T>;
|
||||
};
|
||||
|
||||
export interface InlineRangeProvider {
|
||||
inlineRange$: Signal<InlineRange | null>;
|
||||
setInlineRange(inlineRange: InlineRange | null): void;
|
||||
}
|
||||
|
||||
export class InlineEditor<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
> {
|
||||
static getTextNodesFromElement = getTextNodesFromElement;
|
||||
|
||||
static nativePointToTextPoint = nativePointToTextPoint;
|
||||
|
||||
static textPointToDomPoint = textPointToDomPoint;
|
||||
|
||||
readonly disposables = new DisposableGroup();
|
||||
|
||||
readonly attributeService: AttributeService<TextAttributes> =
|
||||
new AttributeService<TextAttributes>(this);
|
||||
getFormat = this.attributeService.getFormat;
|
||||
normalizeAttributes = this.attributeService.normalizeAttributes;
|
||||
resetMarks = this.attributeService.resetMarks;
|
||||
setAttributeRenderer = this.attributeService.setAttributeRenderer;
|
||||
setAttributeSchema = this.attributeService.setAttributeSchema;
|
||||
setMarks = this.attributeService.setMarks;
|
||||
get marks() {
|
||||
return this.attributeService.marks;
|
||||
}
|
||||
|
||||
readonly textService: InlineTextService<TextAttributes> =
|
||||
new InlineTextService<TextAttributes>(this);
|
||||
deleteText = this.textService.deleteText;
|
||||
formatText = this.textService.formatText;
|
||||
insertLineBreak = this.textService.insertLineBreak;
|
||||
insertText = this.textService.insertText;
|
||||
resetText = this.textService.resetText;
|
||||
setText = this.textService.setText;
|
||||
|
||||
readonly deltaService: DeltaService<TextAttributes> =
|
||||
new DeltaService<TextAttributes>(this);
|
||||
getDeltaByRangeIndex = this.deltaService.getDeltaByRangeIndex;
|
||||
getDeltasByInlineRange = this.deltaService.getDeltasByInlineRange;
|
||||
mapDeltasInInlineRange = this.deltaService.mapDeltasInInlineRange;
|
||||
get embedDeltas() {
|
||||
return this.deltaService.embedDeltas;
|
||||
}
|
||||
|
||||
readonly rangeService: RangeService<TextAttributes> =
|
||||
new RangeService<TextAttributes>(this);
|
||||
focusEnd = this.rangeService.focusEnd;
|
||||
focusIndex = this.rangeService.focusIndex;
|
||||
focusStart = this.rangeService.focusStart;
|
||||
getInlineRangeFromElement = this.rangeService.getInlineRangeFromElement;
|
||||
isFirstLine = this.rangeService.isFirstLine;
|
||||
isLastLine = this.rangeService.isLastLine;
|
||||
isValidInlineRange = this.rangeService.isValidInlineRange;
|
||||
selectAll = this.rangeService.selectAll;
|
||||
syncInlineRange = this.rangeService.syncInlineRange;
|
||||
toDomRange = this.rangeService.toDomRange;
|
||||
toInlineRange = this.rangeService.toInlineRange;
|
||||
getLine = this.rangeService.getLine;
|
||||
getNativeRange = this.rangeService.getNativeRange;
|
||||
getNativeSelection = this.rangeService.getNativeSelection;
|
||||
getTextPoint = this.rangeService.getTextPoint;
|
||||
get lastStartRelativePosition() {
|
||||
return this.rangeService.lastStartRelativePosition;
|
||||
}
|
||||
get lastEndRelativePosition() {
|
||||
return this.rangeService.lastEndRelativePosition;
|
||||
}
|
||||
|
||||
readonly eventService: EventService<TextAttributes> =
|
||||
new EventService<TextAttributes>(this);
|
||||
get isComposing() {
|
||||
return this.eventService.isComposing;
|
||||
}
|
||||
|
||||
readonly renderService: RenderService<TextAttributes> =
|
||||
new RenderService<TextAttributes>(this);
|
||||
waitForUpdate = this.renderService.waitForUpdate;
|
||||
rerenderWholeEditor = this.renderService.rerenderWholeEditor;
|
||||
render = this.renderService.render;
|
||||
get rendering() {
|
||||
return this.renderService.rendering;
|
||||
}
|
||||
|
||||
readonly hooksService: InlineHookService<TextAttributes>;
|
||||
get hooks() {
|
||||
return this.hooksService.hooks;
|
||||
}
|
||||
|
||||
private _eventSource: HTMLElement | null = null;
|
||||
get eventSource() {
|
||||
return this._eventSource;
|
||||
}
|
||||
|
||||
private _isReadonly = false;
|
||||
get isReadonly() {
|
||||
return this._isReadonly;
|
||||
}
|
||||
|
||||
private _mounted = false;
|
||||
get mounted() {
|
||||
return this._mounted;
|
||||
}
|
||||
|
||||
private _rootElement: InlineRootElement<TextAttributes> | null = null;
|
||||
get rootElement() {
|
||||
return this._rootElement;
|
||||
}
|
||||
|
||||
private readonly _inlineRangeProviderOverride: boolean;
|
||||
get inlineRangeProviderOverride() {
|
||||
return this._inlineRangeProviderOverride;
|
||||
}
|
||||
readonly inlineRangeProvider: InlineRangeProvider = {
|
||||
inlineRange$: signal(null),
|
||||
setInlineRange: inlineRange => {
|
||||
this.inlineRange$.value = inlineRange;
|
||||
},
|
||||
};
|
||||
get inlineRange$() {
|
||||
return this.inlineRangeProvider.inlineRange$;
|
||||
}
|
||||
setInlineRange = (inlineRange: InlineRange | null) => {
|
||||
this.inlineRangeProvider.setInlineRange(inlineRange);
|
||||
};
|
||||
getInlineRange = () => {
|
||||
return this.inlineRange$.peek();
|
||||
};
|
||||
|
||||
readonly slots = {
|
||||
mounted: new Subject<void>(),
|
||||
unmounted: new Subject<void>(),
|
||||
renderComplete: new Subject<void>(),
|
||||
textChange: new Subject<void>(),
|
||||
inlineRangeSync: new Subject<Range | null>(),
|
||||
/**
|
||||
* Corresponding to the `compositionUpdate` and `beforeInput` events, and triggered only when the `inlineRange` is not null.
|
||||
*/
|
||||
inputting: new Subject<void>(),
|
||||
/**
|
||||
* Triggered only when the `inlineRange` is not null.
|
||||
*/
|
||||
keydown: new Subject<KeyboardEvent>(),
|
||||
};
|
||||
|
||||
readonly vLineRenderer: ((vLine: VLine) => TemplateResult) | null;
|
||||
|
||||
readonly yText: Y.Text;
|
||||
get yTextDeltas() {
|
||||
return this.yText.toDelta();
|
||||
}
|
||||
get yTextLength() {
|
||||
return this.yText.length;
|
||||
}
|
||||
get yTextString() {
|
||||
return this.yText.toString();
|
||||
}
|
||||
|
||||
readonly isEmbed: (delta: DeltaInsert<TextAttributes>) => boolean;
|
||||
|
||||
constructor(
|
||||
yText: InlineEditor['yText'],
|
||||
ops: {
|
||||
isEmbed?: (delta: DeltaInsert<TextAttributes>) => boolean;
|
||||
hooks?: InlineHookService<TextAttributes>['hooks'];
|
||||
inlineRangeProvider?: InlineRangeProvider;
|
||||
vLineRenderer?: (vLine: VLine) => TemplateResult;
|
||||
} = {}
|
||||
) {
|
||||
if (!yText.doc) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must be attached to a Y.Doc'
|
||||
);
|
||||
}
|
||||
|
||||
if (yText.toString().includes('\r')) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
isEmbed = () => false,
|
||||
hooks = {},
|
||||
inlineRangeProvider,
|
||||
vLineRenderer = null,
|
||||
} = ops;
|
||||
this._inlineRangeProviderOverride = false;
|
||||
this.yText = yText;
|
||||
this.isEmbed = isEmbed;
|
||||
this.vLineRenderer = vLineRenderer;
|
||||
this.hooksService = new InlineHookService(this, hooks);
|
||||
if (inlineRangeProvider) {
|
||||
this.inlineRangeProvider = inlineRangeProvider;
|
||||
this._inlineRangeProviderOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
mount(
|
||||
rootElement: HTMLElement,
|
||||
eventSource: HTMLElement = rootElement,
|
||||
isReadonly = false
|
||||
) {
|
||||
const inlineRoot = rootElement as InlineRootElement<TextAttributes>;
|
||||
inlineRoot.inlineEditor = this;
|
||||
this._rootElement = inlineRoot;
|
||||
this._eventSource = eventSource;
|
||||
this._eventSource.style.outline = 'none';
|
||||
this._rootElement.dataset.vRoot = 'true';
|
||||
this.setReadonly(isReadonly);
|
||||
|
||||
this._rootElement.replaceChildren();
|
||||
|
||||
delete (this.rootElement as any)['_$litPart$'];
|
||||
|
||||
this.eventService.mount();
|
||||
this.rangeService.mount();
|
||||
this.renderService.mount();
|
||||
|
||||
this._mounted = true;
|
||||
this.slots.mounted.next();
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (this.rootElement) {
|
||||
if (this.rootElement.isConnected) {
|
||||
render(nothing, this.rootElement);
|
||||
}
|
||||
this.rootElement.removeAttribute(INLINE_ROOT_ATTR);
|
||||
}
|
||||
this._rootElement = null;
|
||||
this._mounted = false;
|
||||
this.disposables.dispose();
|
||||
this.slots.unmounted.next();
|
||||
}
|
||||
|
||||
setReadonly(isReadonly: boolean): void {
|
||||
const value = isReadonly ? 'false' : 'true';
|
||||
|
||||
if (this.rootElement && this.rootElement.contentEditable !== value) {
|
||||
this.rootElement.contentEditable = value;
|
||||
}
|
||||
|
||||
if (this.eventSource && this.eventSource.contentEditable !== value) {
|
||||
this.eventSource.contentEditable = value;
|
||||
}
|
||||
|
||||
this._isReadonly = isReadonly;
|
||||
}
|
||||
|
||||
transact(fn: () => void): void {
|
||||
const doc = this.yText.doc;
|
||||
if (!doc) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText is not attached to a doc'
|
||||
);
|
||||
}
|
||||
|
||||
doc.transact(fn, doc.clientID);
|
||||
}
|
||||
}
|
||||
14
blocksuite/framework/std/src/inline/range/active.ts
Normal file
14
blocksuite/framework/std/src/inline/range/active.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Check if the active element is in the editor host.
|
||||
* TODO(@mirone): this is a trade-off, we need to use separate awareness store for every store to make sure the selection is isolated.
|
||||
*
|
||||
* @param editorHost - The editor host element.
|
||||
* @returns Whether the active element is in the editor host.
|
||||
*/
|
||||
export function isActiveInEditor(editorHost: HTMLElement) {
|
||||
const currentActiveElement = document.activeElement;
|
||||
if (!currentActiveElement) return false;
|
||||
const currentEditorHost = currentActiveElement?.closest('editor-host');
|
||||
if (!currentEditorHost) return false;
|
||||
return currentEditorHost === editorHost;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user