snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
{
"name": "@blocksuite/std",
"description": "Std for blocksuite blocks",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^2.0.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
"@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.4.11",
"fractional-indexing": "^3.2.0",
"lib0": "^0.2.114",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lz-string": "^1.5.0",
"rehype-parse": "^9.0.0",
"rxjs": "^7.8.2",
"unified": "^11.0.5",
"w3c-keyname": "^2.2.8",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.1.10",
"playwright": "=1.58.2",
"vitest": "^4.1.8"
},
"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.27.5"
}

View 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();
});
});

View File

@@ -0,0 +1,66 @@
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,
SurfaceBlockSchemaExtension,
} from './test-schema.js';
import { testSpecs } from './test-spec.js';
effects();
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
HeadingBlockSchemaExtension,
SurfaceBlockSchemaExtension,
];
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');
});
});

View File

@@ -0,0 +1,418 @@
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { describe, expect, test, vi } from 'vitest';
import { effects } from '../../effects.js';
import type { TestShapeElement } from '../test-gfx-element.js';
import {
RootBlockSchemaExtension,
type SurfaceBlockModel,
SurfaceBlockSchemaExtension,
} from '../test-schema.js';
effects();
const extensions = [RootBlockSchemaExtension, SurfaceBlockSchemaExtension];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const commonSetup = () => {
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 surfaceId = store.addBlock('test:surface', {}, rootId);
const surfaceBlock = store.getBlock(surfaceId)!;
return {
surfaceId,
surfaceModel: surfaceBlock.model as SurfaceBlockModel,
};
};
describe('surface basic', () => {
test('addElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
expect(model.elementModels[0].id).toBe(id);
});
test('removeElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
model.deleteElement(id);
expect(model.elementModels.length).toBe(0);
});
test('updateElement should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
model.updateElement(id, { xywh: '[10,10,200,200]' });
expect(model.elementModels[0].xywh).toBe('[10,10,200,200]');
});
test('getElementById should return element', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
expect(model.getElementById(id)).not.toBeNull();
});
test('getElementById should return null if not found', () => {
const { surfaceModel: model } = commonSetup();
expect(model.getElementById('not-found')).toBeNull();
});
test('created observer should be called', () => {
const { surfaceModel } = commonSetup();
let expectPayload;
const elementAddedCallback = vi.fn(payload => (expectPayload = payload));
surfaceModel.elementAdded.subscribe(elementAddedCallback);
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
expect(elementAddedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
});
});
test('update and props observer should be called', () => {
const { surfaceModel } = commonSetup();
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
const shapeModel = surfaceModel.getElementById(shapeId)!;
let expectPayload;
const elementUpdatedCallback = vi.fn(payload => (expectPayload = payload));
let propsUpdatedPayload;
const propsUpdatedCallback = vi.fn(payload => {
propsUpdatedPayload = payload;
});
surfaceModel.elementUpdated.subscribe(elementUpdatedCallback);
shapeModel.propsUpdated.subscribe(propsUpdatedCallback);
surfaceModel.updateElement(shapeId, {
rotate: 10,
});
expect(elementUpdatedCallback).toHaveBeenCalled();
expect(propsUpdatedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
props: {
rotate: 10,
},
oldValues: {
rotate: 0,
},
});
expect(propsUpdatedPayload).toMatchObject({
key: 'rotate',
});
});
test('delete observer should be called', () => {
const { surfaceModel } = commonSetup();
const shapeId = surfaceModel.addElement({
type: 'testShape',
rotate: 0,
xywh: '[0, 0, 10, 10]',
});
let expectPayload;
const deletedCallback = vi.fn(payload => (expectPayload = payload));
surfaceModel.elementRemoved.subscribe(deletedCallback);
surfaceModel.deleteElement(shapeId);
expect(deletedCallback).toHaveBeenCalled();
expect(expectPayload).toMatchObject({
id: shapeId,
type: 'testShape',
});
});
});
describe('element model', () => {
test('default value should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.rotate).toBe(0);
expect(element.xywh).toBe('[0,0,10,10]');
});
test('defined prop should not be overwritten by default value', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.rotate).toBe(20);
});
test('assign value to model property should update ymap directly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const element = model.getElementById(id)! as TestShapeElement;
expect(element.yMap.get('rotate')).toBe(0);
element.rotate = 30;
expect(element.yMap.get('rotate')).toBe(30);
});
});
describe('stash/pop', () => {
const { surfaceModel: model } = commonSetup();
test('stash and pop should work correctly', () => {
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
expect(elementModel.rotate).toBe(0);
elementModel.stash('rotate');
elementModel.rotate = 10;
expect(elementModel.rotate).toBe(10);
expect(elementModel.yMap.get('rotate')).toBe(0);
elementModel.pop('rotate');
expect(elementModel.rotate).toBe(10);
expect(elementModel.yMap.get('rotate')).toBe(10);
elementModel.rotate = 6;
expect(elementModel.rotate).toBe(6);
expect(elementModel.yMap.get('rotate')).toBe(6);
});
test('assign stashed property should emit event', () => {
const id = model.addElement({
type: 'testShape',
rotate: 4,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.stash('rotate');
const onchange = vi.fn();
const subscription = model.elementUpdated.subscribe(({ id }) => {
subscription.unsubscribe();
onchange(id);
});
elementModel.rotate = 10;
expect(onchange).toHaveBeenCalledWith(id);
});
test('stashed property should also trigger derive decorator', () => {
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.stash('shapeType');
elementModel.shapeType = 'triangle';
// rotation should be 0 'cause of derive decorator
expect(elementModel.rotate).toBe(0);
});
test('non-field property should not allow stash/pop, and should fail silently ', () => {
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
// opacity is a local property, so it should not be stashed
elementModel.stash('opacity');
expect(elementModel['_stashed'].has('opacity')).toBe(false);
// pop the `opacity` should not affect yMap
elementModel.opacity = 0.5;
elementModel.pop('opacity');
expect(elementModel.yMap.has('opacity')).toBe(false);
});
});
describe('derive decorator', () => {
test('derived decorator should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
rotate: 20,
});
const elementModel = model.getElementById(id)! as TestShapeElement;
elementModel.shapeType = 'triangle';
expect(elementModel.rotate).toBe(0);
});
});
describe('local decorator', () => {
test('local decorator should work correctly', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
expect(elementModel.display).toBe(true);
elementModel.display = false;
expect(elementModel.display).toBe(false);
elementModel.opacity = 0.5;
expect(elementModel.opacity).toBe(0.5);
});
test('assign local property should emit event', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
const onchange = vi.fn();
const subscription = model.elementUpdated.subscribe(({ id }) => {
subscription.unsubscribe();
onchange(id);
});
const onPropChange = vi.fn();
elementModel.propsUpdated.subscribe(({ key }) => {
onPropChange(key);
});
elementModel.display = false;
expect(elementModel.display).toBe(false);
expect(onchange).toHaveBeenCalledWith(id);
expect(onPropChange).toHaveBeenCalledWith('display');
});
});
describe('convert decorator', () => {
test('convert decorator', () => {
const { surfaceModel: model } = commonSetup();
const id = model.addElement({
type: 'testShape',
});
const elementModel = model.getElementById(id)! as TestShapeElement;
// @ts-expect-error test needed
elementModel.shapeType = 'otherImpossibleType';
expect(elementModel.shapeType).toBe('rect');
});
});
describe('surface group index cache', () => {
test('syncGroupChildrenIndex should replace outdated parent mappings', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-1', ['a', 'b'], []);
expect(model._parentGroupMap.get('a')).toBe('group-1');
expect(model._parentGroupMap.get('b')).toBe('group-1');
model._syncGroupChildrenIndex('group-1', ['b', 'c']);
expect(model._parentGroupMap.has('a')).toBe(false);
expect(model._parentGroupMap.get('b')).toBe('group-1');
expect(model._parentGroupMap.get('c')).toBe('group-1');
});
test('removeGroupFromChildrenIndex should clear both child snapshot and reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-2', ['x', 'y'], []);
model._removeGroupFromChildrenIndex('group-2');
expect(model._groupChildIdsMap.has('group-2')).toBe(false);
expect(model._parentGroupMap.has('x')).toBe(false);
expect(model._parentGroupMap.has('y')).toBe(false);
});
test('getGroup should recover from stale cache and update reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
const shapeId = surfaceModel.addElement({
type: 'testShape',
});
const shape = surfaceModel.getElementById(shapeId)!;
const fakeGroup = {
id: 'group-fallback',
hasChild: (element: { id: string }) => element.id === shapeId,
};
model._groupLikeModels.set(fakeGroup.id, fakeGroup);
model._parentGroupMap.set(shapeId, 'stale-group-id');
expect(surfaceModel.getGroup(shapeId)).toBe(fakeGroup);
expect(model._parentGroupMap.get(shapeId)).toBe(fakeGroup.id);
expect(model._parentGroupMap.has('stale-group-id')).toBe(false);
const otherShapeId = surfaceModel.addElement({
type: 'testShape',
});
model._parentGroupMap.set(otherShapeId, 'another-missing-group');
expect(surfaceModel.getGroup(otherShapeId)).toBeNull();
expect(model._parentGroupMap.has(otherShapeId)).toBe(false);
// keep one explicit check on element-based lookup path
expect(surfaceModel.getGroup(shape as any)).toBe(fakeGroup);
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, test, vi } from 'vitest';
import {
type GfxGroupCompatibleInterface,
gfxGroupCompatibleSymbol,
} from '../../gfx/model/base.js';
import type { GfxModel } from '../../gfx/model/model.js';
import {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
} from '../../utils/tree.js';
type TestElement = {
id: string;
group: TestGroup | null;
groups: TestGroup[];
};
type TestGroup = TestElement & {
[gfxGroupCompatibleSymbol]: true;
childIds: string[];
childElements: GfxModel[];
addChild: (element: GfxModel) => void;
removeChild: (element: GfxModel) => void;
hasChild: (element: GfxModel) => boolean;
hasDescendant: (element: GfxModel) => boolean;
};
const createElement = (id: string): TestElement => ({
id,
group: null,
groups: [],
});
const createGroup = (id: string): TestGroup => {
const group: TestGroup = {
id,
[gfxGroupCompatibleSymbol]: true,
group: null,
groups: [],
childIds: [],
childElements: [],
addChild(element: GfxModel) {
const child = element as unknown as TestElement;
if (this.childElements.includes(element)) {
return;
}
this.childElements.push(element);
this.childIds.push(child.id);
child.group = this;
child.groups = [...this.groups, this];
},
removeChild(element: GfxModel) {
const child = element as unknown as TestElement;
this.childElements = this.childElements.filter(item => item !== element);
this.childIds = this.childIds.filter(id => id !== child.id);
if (child.group === this) {
child.group = null;
child.groups = [];
}
},
hasChild(element: GfxModel) {
return this.childElements.includes(element);
},
hasDescendant(element: GfxModel) {
return descendantElementsImpl(
this as unknown as GfxGroupCompatibleInterface
).includes(element);
},
};
return group;
};
describe('tree utils', () => {
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
addChildren: vi.fn(),
addChild: vi.fn(),
};
batchAddChildren(container as any, [a, a, b]);
expect(container.addChildren).toHaveBeenCalledTimes(1);
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
expect(container.addChild).not.toHaveBeenCalled();
});
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
removeChild: vi.fn(),
};
batchRemoveChildren(container as any, [a, a, b]);
expect(container.removeChild).toHaveBeenCalledTimes(2);
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
});
test('getTopElements removes descendants when ancestors are selected', () => {
const root = createGroup('root');
const nested = createGroup('nested');
const leafA = createElement('leaf-a');
const leafB = createElement('leaf-b');
const leafC = createElement('leaf-c');
root.addChild(leafA as unknown as GfxModel);
root.addChild(nested as unknown as GfxModel);
nested.addChild(leafB as unknown as GfxModel);
const result = getTopElements([
root as unknown as GfxModel,
nested as unknown as GfxModel,
leafA as unknown as GfxModel,
leafB as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
expect(result).toEqual([
root as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
});
test('descendantElementsImpl stops on cyclic graph', () => {
const groupA = createGroup('group-a');
const groupB = createGroup('group-b');
groupA.addChild(groupB as unknown as GfxModel);
groupB.addChild(groupA as unknown as GfxModel);
const descendants = descendantElementsImpl(groupA as unknown as any);
expect(descendants).toHaveLength(2);
expect(new Set(descendants).size).toBe(2);
});
test('canSafeAddToContainer blocks self and circular descendants', () => {
const parent = createGroup('parent');
const child = createGroup('child');
const unrelated = createElement('plain');
parent.addChild(child as unknown as GfxModel);
expect(
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(
parent as unknown as any,
unrelated as unknown as any
)
).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View 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);
});
});

View File

@@ -0,0 +1,66 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, test } from 'vitest';
import { shouldDeactivateEditorOnFocusOut } from '../../inline/range/active.js';
import { RANGE_SYNC_EXCLUDE_ATTR } from '../../inline/range/consts.js';
afterEach(() => {
document.body.replaceChildren();
});
describe('editor active helpers', () => {
test('keeps editor active when focus moves into an excluded widget', () => {
const host = document.createElement('editor-host');
const paragraph = document.createElement('div');
host.append(paragraph);
const widgetRoot = document.createElement('div');
widgetRoot.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
const widgetButton = document.createElement('button');
widgetRoot.append(widgetButton);
document.body.append(host, widgetRoot);
expect(shouldDeactivateEditorOnFocusOut(host, widgetButton)).toBe(false);
});
test('keeps editor active when focus stays inside the editor host', () => {
const host = document.createElement('editor-host');
const input = document.createElement('input');
host.append(input);
document.body.append(host);
expect(shouldDeactivateEditorOnFocusOut(host, input)).toBe(false);
});
test('deactivates editor when focus moves to a regular external control', () => {
const host = document.createElement('editor-host');
const paragraph = document.createElement('div');
host.append(paragraph);
const externalButton = document.createElement('button');
document.body.append(host, externalButton);
expect(shouldDeactivateEditorOnFocusOut(host, externalButton)).toBe(true);
});
test('keeps editor active on focusout when related target is null', () => {
// Focus leaving to nowhere (e.g. block-level selection) must not deactivate
// the editor via `focusout`; the host `blur` handler owns that case.
const host = document.createElement('editor-host');
document.body.append(host);
expect(shouldDeactivateEditorOnFocusOut(host, null)).toBe(false);
});
test('keeps editor active when the related target is not a DOM node', () => {
const host = document.createElement('editor-host');
document.body.append(host);
expect(shouldDeactivateEditorOnFocusOut(host, new EventTarget())).toBe(
false
);
});
});

View File

@@ -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,
},
},
],
[],
[],
]);
});

View File

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

View File

@@ -0,0 +1,144 @@
import { expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import { effects } from '../../effects.js';
import { InlineEditor } from '../../inline/index.js';
effects();
async function setupInlineEditor(text: string) {
const yDoc = new Y.Doc();
const yText = yDoc.getText('text');
yText.insert(0, text);
const editor = new InlineEditor(yText);
const root = document.createElement('div');
const outside = document.createElement('div');
outside.textContent = 'outside';
document.body.append(root, outside);
editor.mount(root);
await editor.waitForUpdate();
return { editor, root, outside };
}
function setNativeSelection(range: Range) {
const selection = document.getSelection();
if (!selection) {
throw new Error('Selection is not available');
}
selection.removeAllRanges();
selection.addRange(range);
}
function clearNativeSelection() {
const selection = document.getSelection();
selection?.removeAllRanges();
}
async function teardownInlineEditor(
ctx: Awaited<ReturnType<typeof setupInlineEditor>>
) {
clearNativeSelection();
ctx.editor.unmount();
ctx.root.remove();
ctx.outside.remove();
}
test('beforeinput prevents native edits for selection partially outside inline root', async () => {
const ctx = await setupInlineEditor('hello');
try {
const range = ctx.editor.toDomRange({ index: 1, length: 0 });
expect(range).not.toBeNull();
range!.setEnd(ctx.outside, 0);
setNativeSelection(range!);
const preventDefault = vi.fn();
const event = {
inputType: 'deleteContentForward',
data: null,
dataTransfer: null,
preventDefault,
stopPropagation: vi.fn(),
getTargetRanges: () => [],
} as unknown as InputEvent;
await (ctx.editor.eventService as any)._onBeforeInput(event);
expect(preventDefault).toHaveBeenCalledOnce();
expect(ctx.editor.yTextString).toBe('h');
} finally {
await teardownInlineEditor(ctx);
}
});
test('beforeinput does not intercept when selection spans another inline root', async () => {
const ctx1 = await setupInlineEditor('abc');
const ctx2 = await setupInlineEditor('xyz');
try {
const startRange = ctx1.editor.toDomRange({ index: 1, length: 0 });
const endRange = ctx2.editor.toDomRange({ index: 1, length: 0 });
expect(startRange).not.toBeNull();
expect(endRange).not.toBeNull();
const selectionRange = document.createRange();
selectionRange.setStart(
startRange!.startContainer,
startRange!.startOffset
);
selectionRange.setEnd(endRange!.endContainer, endRange!.endOffset);
setNativeSelection(selectionRange);
const preventDefault = vi.fn();
const event = {
inputType: 'deleteContentForward',
data: null,
dataTransfer: null,
preventDefault,
stopPropagation: vi.fn(),
getTargetRanges: () => [],
} as unknown as InputEvent;
await (ctx1.editor.eventService as any)._onBeforeInput(event);
expect(preventDefault).not.toHaveBeenCalled();
expect(ctx1.editor.yTextString).toBe('abc');
} finally {
await teardownInlineEditor(ctx1);
await teardownInlineEditor(ctx2);
}
});
test('beforeinput ignores un-resolvable target range and still applies input', async () => {
const ctx = await setupInlineEditor('hello world');
try {
const range = ctx.editor.toDomRange({ index: 0, length: 5 });
expect(range).not.toBeNull();
setNativeSelection(range!);
const preventDefault = vi.fn();
const event = {
inputType: 'insertText',
data: 'x',
dataTransfer: null,
preventDefault,
stopPropagation: vi.fn(),
getTargetRanges: () => [
{
startContainer: ctx.outside,
startOffset: 0,
endContainer: ctx.outside,
endOffset: 0,
},
],
} as unknown as InputEvent;
await (ctx.editor.eventService as any)._onBeforeInput(event);
expect(preventDefault).toHaveBeenCalledOnce();
expect(ctx.editor.yTextString).toBe('x world');
} finally {
await teardownInlineEditor(ctx);
}
});

View File

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

View 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 });
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, test, vi } from 'vitest';
import { UIEventState, UIEventStateContext } from '../event/base.js';
import { androidBindKeymapPatch, bindKeymap } from '../event/keymap.js';
const createKeyboardEvent = (options: {
key: string;
keyCode: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {
key: options.key,
altKey: options.altKey ?? false,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false,
});
Object.defineProperty(event, 'keyCode', {
configurable: true,
get: () => options.keyCode,
});
Object.defineProperty(event, 'which', {
configurable: true,
get: () => options.keyCode,
});
return event;
};
const createCtx = (event: KeyboardEvent) => {
return {
get(name: string) {
if (name === 'keyboardState') {
return { raw: event };
}
return undefined;
},
} as any;
};
describe('bindKeymap', () => {
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
let handled = false;
const handler = bindKeymap({
'Ctrl-f': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'а',
keyCode: 70,
ctrlKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback for Alt+locale-character letter input', () => {
let handled = false;
const handler = bindKeymap({
'Alt-s': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'ś',
keyCode: 83,
altKey: true,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
let handled = false;
const handler = bindKeymap({
'Alt-0': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'º',
keyCode: 48,
altKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback on non-ASCII input without modifiers', () => {
let handled = false;
const handler = bindKeymap({
'[': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'х',
keyCode: 219,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
});
describe('androidBindKeymapPatch', () => {
const beforeInputCtx = (inputType: string) => {
const event = new InputEvent('beforeinput', {
inputType,
cancelable: true,
});
return { ctx: UIEventStateContext.from(new UIEventState(event)), event };
};
test('routes deleteContentBackward to the Backspace binding', () => {
const backspace = vi.fn(() => true);
const handler = androidBindKeymapPatch({ Backspace: backspace });
const { ctx } = beforeInputCtx('deleteContentBackward');
expect(handler(ctx)).toBe(true);
expect(backspace).toHaveBeenCalledOnce();
});
test('routes insertParagraph to the Enter binding', () => {
const enter = vi.fn((ctx: UIEventStateContext) => {
ctx.get('keyboardState').raw.preventDefault();
return true;
});
const handler = androidBindKeymapPatch({ Enter: enter });
const { ctx, event } = beforeInputCtx('insertParagraph');
const preventDefault = vi.spyOn(event, 'preventDefault');
expect(handler(ctx)).toBe(true);
expect(enter).toHaveBeenCalledOnce();
expect(preventDefault).toHaveBeenCalledOnce();
expect(ctx.get('keyboardState').raw.key).toBe('Enter');
expect(ctx.get('keyboardState').composing).toBe(false);
});
test('propagates preventDefault when the binding returns false', () => {
const backspace = vi.fn((ctx: UIEventStateContext) => {
ctx.get('keyboardState').raw.preventDefault();
return false;
});
const handler = androidBindKeymapPatch({ Backspace: backspace });
const { ctx, event } = beforeInputCtx('deleteContentBackward');
expect(handler(ctx)).toBe(false);
expect(event.defaultPrevented).toBe(true);
});
test('does nothing for insertParagraph without an Enter binding', () => {
const handler = androidBindKeymapPatch({ Backspace: vi.fn(() => true) });
const { ctx } = beforeInputCtx('insertParagraph');
expect(handler(ctx)).toBe(false);
expect(ctx.has('keyboardState')).toBe(false);
});
test('ignores non-input events', () => {
const enter = vi.fn(() => true);
const backspace = vi.fn(() => true);
const ctx = UIEventStateContext.from(
new UIEventState(new KeyboardEvent('keydown', { key: 'Enter' }))
);
expect(
androidBindKeymapPatch({ Enter: enter, Backspace: backspace })(ctx)
).toBeUndefined();
expect(enter).not.toHaveBeenCalled();
expect(backspace).not.toHaveBeenCalled();
});
test('ignores unrelated input types', () => {
const enter = vi.fn(() => true);
const backspace = vi.fn(() => true);
const handler = androidBindKeymapPatch({
Enter: enter,
Backspace: backspace,
});
const { ctx } = beforeInputCtx('insertText');
expect(handler(ctx)).toBe(false);
expect(enter).not.toHaveBeenCalled();
expect(backspace).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,61 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { BlockComponent, GfxBlockComponent } from '../view/index.js';
import type {
HeadingBlockModel,
NoteBlockModel,
RootBlockModel,
SurfaceBlockModel,
TestGfxBlockModel,
} 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> `;
}
}
@customElement('test-surface-block')
export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
override renderBlock() {
return html`
<div class="test-surface-block">${this.renderChildren(this.model)}</div>
`;
}
}
@customElement('test-gfx-block')
export class TestGfxBlockComponent extends GfxBlockComponent<TestGfxBlockModel> {
override renderGfxBlock() {
return html`
<div class="test-gfx-block">${this.renderChildren(this.model)}</div>
`;
}
}

View 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[] = [];
}

View File

@@ -0,0 +1,44 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
convert,
derive,
field,
GfxLocalElementModel,
GfxPrimitiveElementModel,
} from '../gfx/index.js';
export class TestShapeElement extends GfxPrimitiveElementModel {
get type() {
return 'testShape';
}
@field()
accessor rotate: number = 0;
@field()
accessor xywh: SerializedXYWH = '[0,0,10,10]';
@convert(val => {
if (['rect', 'triangle'].includes(val)) {
return val;
}
return 'rect';
})
@derive(val => {
if (val === 'triangle') {
return {
rotate: 0,
};
}
return {};
})
@field()
accessor shapeType: 'rect' | 'triangle' = 'rect';
}
export class TestLocalElement extends GfxLocalElementModel {
override type: string = 'testLocal';
}

View File

@@ -0,0 +1,127 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
BlockModel,
BlockSchemaExtension,
defineBlockSchema,
} from '@blocksuite/store';
import * as Y from 'yjs';
import { SurfaceBlockModel as BaseSurfaceModel } from '../gfx/index.js';
import {
GfxCompatibleBlockModel,
type GfxCompatibleProps,
} from '../gfx/model/gfx-block-model.js';
import { TestShapeElement } from './test-gfx-element.js';
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', 'test:surface'],
},
});
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']>
> {}
export const SurfaceBlockSchema = defineBlockSchema({
flavour: 'test:surface',
props: internal => ({
elements: internal.Boxed<Y.Map<Y.Map<unknown>>>(new Y.Map()),
}),
metadata: {
version: 1,
role: 'hub',
parent: ['test:page'],
},
toModel: () => new SurfaceBlockModel(),
});
export const SurfaceBlockSchemaExtension =
BlockSchemaExtension(SurfaceBlockSchema);
export class SurfaceBlockModel extends BaseSurfaceModel {
override _init() {
this._extendElement({
testShape: TestShapeElement,
});
super._init();
}
}
type GfxTestBlockProps = {
xywh: SerializedXYWH;
rotate: number;
index: string;
} & GfxCompatibleProps;
export const TestGfxBlockSchema = defineBlockSchema({
flavour: 'test:gfx-block',
props: () =>
({
xywh: '[0,0,10,10]' as SerializedXYWH,
rotate: 0,
index: 'a0',
lockedBySelf: false,
}) as GfxTestBlockProps,
metadata: {
version: 1,
role: 'content',
parent: ['test:surface'],
},
toModel: () => new TestGfxBlockModel(),
});
export const TestGfxBlockSchemaExtension =
BlockSchemaExtension(TestGfxBlockSchema);
export class TestGfxBlockModel extends GfxCompatibleBlockModel<GfxTestBlockProps>(
BlockModel
) {}

View File

@@ -0,0 +1,27 @@
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`;
}),
BlockViewExtension('test:surface', literal`test-surface-block`),
BlockViewExtension('test:gfx-block', literal`test-gfx-block`),
];

View 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
);
},
};
}

View File

@@ -0,0 +1,323 @@
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 key = 'clipboard';
protected 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.store.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 filtered = (
await Promise.all(
adapterKeys.map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
return [type, item];
}
return null;
})
)
).filter((adapter): adapter is string[] => Boolean(adapter));
return {
...items,
...Object.fromEntries(filtered),
};
});
};
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.store.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: <T extends Record<string, unknown>>(items: T) => Promise<T> | T
) {
const items = await updateItems<
Partial<{
'text/plain': string;
'text/html': string;
'image/png': string | Blob;
}>
>({
'text/plain': '',
'text/html': '',
'image/png': '',
});
const text = items['text/plain'] ?? '';
const innerHTML = items['text/html'] ?? '';
const image = items['image/png'];
delete items['text/plain'];
delete items['text/html'];
const clipboardItems: Record<string, Blob> = {};
if (image) {
const type = 'image/png';
delete items[type];
if (typeof image === 'string') {
clipboardItems[type] = new Blob([image], { type });
} else if (image instanceof Blob) {
clipboardItems[type] = image;
}
}
if (text.length > 0) {
const type = 'text/plain';
clipboardItems[type] = new Blob([text], { type });
}
const hasInnerHTML = Boolean(innerHTML.length);
const isEmpty = Object.keys(clipboardItems).length === 0;
// If there are no items, fall back to snapshot.
if (hasInnerHTML || isEmpty) {
const type = 'text/html';
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot="${snapshot}">${innerHTML}</div>`;
clipboardItems[type] = new Blob([html], { type });
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
}
}

View File

@@ -0,0 +1,2 @@
export * from './clipboard';
export * from './clipboard-adapter';

View 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';
}

View File

@@ -0,0 +1 @@
export const cmdSymbol = Symbol('cmds');

View File

@@ -0,0 +1,3 @@
export * from './consts.js';
export * from './manager.js';
export * from './types.js';

View 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;
}

View 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];
};

View 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);
}

View 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;
}

View File

@@ -0,0 +1,55 @@
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);
}
}

View File

@@ -0,0 +1,145 @@
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;
private readonly _press = (event: KeyboardEvent) => {
if (!this._shouldTrigger(event)) {
return;
}
const keyboardEventState = new KeyboardEventState({
event,
composing: this.composition,
});
this._dispatcher.run(
'keyPress',
this._createContext(event, keyboardEventState)
);
};
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);
})
);
}
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,
'keypress',
this._press
);
this._dispatcher.disposables.addFromEvent(
document,
'compositionstart',
() => {
this.composition = true;
},
{
capture: true,
}
);
this._dispatcher.disposables.addFromEvent(
document,
'compositionend',
() => {
this.composition = false;
},
{
capture: true,
}
);
}
}

View 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());
}
}

View 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;
}
// oxlint-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
);
}
}

View File

@@ -0,0 +1,458 @@
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 { shouldDeactivateEditorOnFocusOut } from '../inline/range/active.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',
'keyPress',
'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;
};
const syntheticEventNames = new Set(['click', 'doubleClick', 'tripleClick']);
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(document, 'focusout', e => {
if (shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)) {
this._setActive(false);
}
});
this.disposables.addFromEvent(this.host, 'blur', e => {
if (_dragging) {
return;
}
if (
!e.relatedTarget ||
shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)
) {
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);
});
// When the document is hidden, the event dispatcher should be inactive
this.disposables.addFromEvent(document, 'visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this._setActive(false);
}
});
// When the selection is outside the host, the event dispatcher should be inactive
this.disposables.addFromEvent(document, 'selectionchange', () => {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0) return;
const { anchorNode, focusNode } = sel;
if (
(anchorNode && !this.host.contains(anchorNode)) ||
(focusNode && !this.host.contains(focusNode))
) {
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.store.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) {
// Only stop propagation for non-synthetic events
if (!syntheticEventNames.has(name)) {
context.get('defaultState').event.stopPropagation();
}
return;
}
}
}
override unmounted() {
this.disposables.dispose();
}
}

View File

@@ -0,0 +1,4 @@
export * from './base.js';
export * from './dispatcher.js';
export * from './keymap.js';
export * from './state/index.js';

View File

@@ -0,0 +1,165 @@
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';
import { KeyboardEventState } from './state/index.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;
}
}
// For non-standard keyboards, fallback to keyCode only when modifier keys are pressed.
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
// because the user intends to type that character, not trigger a shortcut bound to the physical key.
// See: https://github.com/toeverything/AFFiNE/issues/14059
const hasModifier =
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
const baseName = base[event.keyCode];
const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
const isAltInputChar =
event.altKey && !event.ctrlKey && !event.metaKey && !isSingleAscii;
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
// while preventing Alt-based locale input characters from triggering letter shortcuts.
const isDigitBaseKey =
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
if (
hasModifier &&
baseName &&
baseName !== name &&
!(isAltInputChar && !isDigitBaseKey)
) {
const fromCode = map[modifiers(baseName, event)];
if (fromCode && fromCode(ctx)) {
return true;
}
}
return false;
};
}
// In some IME of Android like, 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;
const bindingName =
event.inputType === 'deleteContentBackward'
? 'Backspace'
: event.inputType === 'deleteContentForward'
? 'Delete'
: event.inputType === 'insertParagraph'
? 'Enter'
: undefined;
if (!bindingName || !(bindingName in bindings)) return false;
if (!ctx.has('keyboardState')) {
const keyboardEvent = new KeyboardEvent('keydown', {
key: bindingName,
code: bindingName,
cancelable: true,
});
Object.defineProperty(keyboardEvent, 'isComposing', {
configurable: true,
value: event.isComposing,
});
ctx.add(
new KeyboardEventState({
event: keyboardEvent,
composing: event.isComposing,
})
);
}
const handled = bindings[bindingName](ctx);
if (handled || ctx.get('keyboardState').raw.defaultPrevented) {
event.preventDefault();
}
return handled;
};
}

View 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;
}
}

View 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;
}
}

View 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';

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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>;

View 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);
},
};
}

View 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;
}

View 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);
}
}

View 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];

View 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();
}
}

View 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,
}));
},
};
}

View 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';

View 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,
});
},
};
}

View 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() {}
}

View File

@@ -0,0 +1,22 @@
import { BlockServiceIdentifier } from '../identifier.js';
import { LifeCycleWatcher } from './lifecycle-watcher.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();
});
}
}

View 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();
}
}

View 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);
},
};
}

View File

@@ -0,0 +1,284 @@
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 { 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;
}
/**
* 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);
}
}

View 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;

View 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() {}
}

View File

@@ -0,0 +1,513 @@
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,
};
export type BuiltInFilterModelMap = {
block: GfxBlockElementModel;
canvas: GfxPrimitiveElementModel;
local: GfxLocalElementModel;
};
export type BuiltInFilterType = keyof typeof typeFilters;
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 BuiltInFilterType = 'canvas' | 'block'>(
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;
}
): BuiltInFilterModelMap[T][];
search<T extends BuiltInFilterType = 'canvas' | 'block'>(
bound: IBound,
options: {
strict?: boolean | undefined;
useSet: true;
filter?: (T | FilterFunc)[] | FilterFunc;
}
): Set<BuiltInFilterModelMap[T]>;
search<T extends BuiltInFilterType = 'canvas' | 'block'>(
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.getModelById(payload.id);
if (!model) return;
if (payload.props.key === 'xywh' && canBeRenderedAsGfxBlock(model)) {
this.update(model);
}
}
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<string, number>(surface.childMap.peek());
disposables.add(
surface.childMap.subscribe(currentChildMap => {
currentChildMap.forEach((_, id) => {
if (lastChildMap.has(id)) {
lastChildMap.delete(id);
return;
}
});
lastChildMap.forEach((_, id) => {
const model = store.getModelById(id);
if (model) {
this.remove(model as GfxBlockElementModel);
}
});
currentChildMap.forEach((_, id) => {
const model = store.getModelById(id);
if (model) {
this.add(model as GfxBlockElementModel);
}
});
lastChildMap = new Map(currentChildMap);
})
);
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);
}
})
);
}
}
}

View 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>;

View File

@@ -0,0 +1,121 @@
export { generateKeyBetweenV2 } from '../utils/fractional-indexing.js';
export {
compare as compareLayer,
renderableInEdgeless,
SortOrder,
} from '../utils/layer.js';
export {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
hasDescendantElementImpl,
} from '../utils/tree.js';
export { GfxController } from './controller.js';
export type { CursorType, StandardCursor } from './cursor.js';
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
export { GridManager } from './grid.js';
export { GfxControllerIdentifier } from './identifiers.js';
export type {
BoxSelectionContext,
DragEndContext,
DragExtensionInitializeContext,
DragInitializationOption,
DragMoveContext,
DragStartContext,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
GfxInteractivityContext,
GfxViewInteractionConfig,
ResizeConstraint,
ResizeEndContext,
ResizeHandle,
ResizeMoveContext,
ResizeStartContext,
RotateConstraint,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
SelectContext,
} from './interactivity/index.js';
export {
GfxViewEventManager,
GfxViewInteractionExtension,
InteractivityExtension,
InteractivityIdentifier,
InteractivityManager,
} from './interactivity/index.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, isPrimitiveModel } 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 { measureOperation } from './perf.js';
export { createRafCoalescer, type RafCoalescer } from './raf-coalescer.js';
export { GfxSelectionManager } from './selection.js';
export {
SurfaceMiddlewareBuilder,
SurfaceMiddlewareExtension,
} from './surface-middleware.js';
export {
BaseTool,
type ToolOptions,
type ToolOptionWithType,
type ToolType,
} 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';

View File

@@ -0,0 +1,48 @@
import type { PointerEventState, UIEventState } from '../../event';
export type SupportedEvents =
| 'click'
| 'dblclick'
| 'pointerdown'
| 'pointerenter'
| 'pointerleave'
| 'pointermove'
| 'pointerup'
| 'dragstart'
| 'dragmove'
| 'dragend';
export type GfxInteractivityContext<
EventState extends UIEventState = PointerEventState,
RawEvent extends Event = EventState['event'],
> = {
event: EventState;
/**
* The raw dom event.
*/
raw: RawEvent;
/**
* Prevent the default gfx interaction
*/
preventDefault: () => void;
};
export const createInteractionContext = (event: PointerEventState) => {
let preventDefaultState = false;
return {
context: {
event,
raw: event.raw,
preventDefault: () => {
preventDefaultState = true;
event.raw.preventDefault();
},
},
get preventDefaultState() {
return preventDefaultState;
},
};
};

View File

@@ -0,0 +1,211 @@
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';
import type { GfxModel } from '../../model/model.js';
import type { GfxInteractivityContext, SupportedEvents } from '../event.js';
import type { ExtensionElementsCloneContext } from '../types/clone.js';
import type {
DragExtensionInitializeContext,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from '../types/drag.js';
import type {
ExtensionElementResizeContext,
ExtensionElementResizeEndContext,
ExtensionElementResizeMoveContext,
ExtensionElementResizeStartContext,
} from '../types/resize.js';
import type { ExtensionElementSelectContext } from '../types/select.js';
export const InteractivityExtensionIdentifier =
createIdentifier<InteractivityExtension>('interactivity-extension');
export class InteractivityExtension extends Extension {
static key: string;
get std() {
return this.gfx.std;
}
event: Omit<InteractivityEventAPI, 'emit'> = new InteractivityEventAPI();
action: Omit<InteractivityActionAPI, 'emit'> = new InteractivityActionAPI();
constructor(protected readonly gfx: GfxController) {
super();
}
mounted() {}
/**
* Override this method should call `super.unmounted()`
*/
unmounted() {
this.event.destroy();
this.action.destroy();
}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'key is not defined in the InteractivityExtension'
);
}
di.add(
this as unknown as { new (gfx: GfxController): InteractivityExtension },
[GfxControllerIdentifier]
);
di.addImpl(InteractivityExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
}
export class InteractivityEventAPI {
private readonly _handlersMap = new Map<
SupportedEvents,
((evt: GfxInteractivityContext) => void)[]
>();
on(
eventName: SupportedEvents,
handler: (evt: GfxInteractivityContext) => void
) {
const handlers = this._handlersMap.get(eventName) ?? [];
handlers.push(handler);
this._handlersMap.set(eventName, handlers);
return () => {
const idx = handlers.indexOf(handler);
if (idx > -1) {
handlers.splice(idx, 1);
}
};
}
emit(eventName: SupportedEvents, evt: GfxInteractivityContext) {
const handlers = this._handlersMap.get(eventName);
if (!handlers) {
return;
}
for (const handler of handlers) {
handler(evt);
}
}
destroy() {
this._handlersMap.clear();
}
}
export type ActionContextMap = {
dragInitialize: {
context: DragExtensionInitializeContext;
returnType: {
onDragStart?: (context: ExtensionDragStartContext) => void;
onDragMove?: (context: ExtensionDragMoveContext) => void;
onDragEnd?: (context: ExtensionDragEndContext) => void;
clear?: () => void;
};
};
elementsClone: {
context: ExtensionElementsCloneContext;
returnType: Promise<
| {
elements: GfxModel[];
}
| undefined
>;
};
elementResize: {
context: ExtensionElementResizeContext;
returnType: {
onResizeStart?: (context: ExtensionElementResizeStartContext) => void;
onResizeMove?: (context: ExtensionElementResizeMoveContext) => void;
onResizeEnd?: (context: ExtensionElementResizeEndContext) => void;
};
};
elementSelect: {
context: ExtensionElementSelectContext;
returnType: void;
};
};
export class InteractivityActionAPI {
private readonly _handlers: Partial<{
[K in keyof ActionContextMap]: (
ctx: ActionContextMap[K]['context']
) => ActionContextMap[K]['returnType'];
}> = {};
onDragInitialize(
handler: (
ctx: ActionContextMap['dragInitialize']['context']
) => ActionContextMap['dragInitialize']['returnType']
) {
this._handlers['dragInitialize'] = handler;
return () => {
delete this._handlers['dragInitialize'];
};
}
onElementResize(
handler: (
ctx: ActionContextMap['elementResize']['context']
) => ActionContextMap['elementResize']['returnType']
) {
this._handlers['elementResize'] = handler;
return () => {
return delete this._handlers['elementResize'];
};
}
onRequestElementsClone(
handler: (
ctx: ActionContextMap['elementsClone']['context']
) => ActionContextMap['elementsClone']['returnType']
) {
this._handlers['elementsClone'] = handler;
return () => {
return delete this._handlers['elementsClone'];
};
}
onElementSelect(
handler: (
ctx: ActionContextMap['elementSelect']['context']
) => ActionContextMap['elementSelect']['returnType']
) {
this._handlers['elementSelect'] = handler;
return () => {
return delete this._handlers['elementSelect'];
};
}
emit<K extends keyof ActionContextMap>(
event: K,
context: ActionContextMap[K]['context']
): ActionContextMap[K]['returnType'] | undefined {
const handler = this._handlers[event];
return handler?.(context);
}
destroy() {
for (const key in this._handlers) {
delete this._handlers[key as keyof typeof this._handlers];
}
}
}

View File

@@ -0,0 +1,134 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockStdScope } from '../../../scope';
import type { GfxBlockComponent } from '../../../view';
import type { GfxController, GfxModel } from '../..';
import type { GfxElementModelView } from '../../view/view';
import type {
BeforeResizeContext,
BeforeRotateContext,
ResizeConstraint,
ResizeEndContext,
ResizeMoveContext,
ResizeStartContext,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
SelectableContext,
SelectContext,
} from '../types/view';
type ExtendedViewContext<
T extends GfxBlockComponent | GfxElementModelView,
Context,
DefaultReturnType = void,
> = {
/**
* The default function of the interaction.
* If the interaction is handled by the extension, the default function will not be executed.
* But extension can choose to call the default function by `context.default(context)` if needed.
*/
default: (context: Context) => DefaultReturnType;
model: T['model'];
view: T;
};
type ViewInteractionHandleContext<
T extends GfxBlockComponent | GfxElementModelView,
> = {
std: BlockStdScope;
gfx: GfxController;
view: T;
model: T['model'];
/**
* Used to add an element to resize list.
* @param model
*/
add(element: GfxModel): void;
/**
* Used to remove an element from resize list.
* @param element
*/
delete(element: GfxModel): void;
};
export type GfxViewInteractionConfig<
T extends GfxBlockComponent | GfxElementModelView =
| GfxBlockComponent
| GfxElementModelView,
> = {
readonly resizeConstraint?: ResizeConstraint;
/**
* The function that will be called when the view is resized.
* You can add or delete the resize element before resize starts in this function.,
* And return handlers to customize the resize behavior.
* @param context
* @returns
*/
handleResize?: (context: ViewInteractionHandleContext<T>) => {
/**
* Called before resize starts. When this method is called, the resize elements are confirmed and will not be changed.
* You can set the resize constraint in this method.
* @param context
* @returns
*/
beforeResize?: (context: BeforeResizeContext) => void;
onResizeStart?(
context: ResizeStartContext & ExtendedViewContext<T, ResizeStartContext>
): void;
onResizeMove?(
context: ResizeMoveContext & ExtendedViewContext<T, ResizeMoveContext>
): void;
onResizeEnd?(
context: ResizeEndContext & ExtendedViewContext<T, ResizeEndContext>
): void;
};
handleRotate?: (context: ViewInteractionHandleContext<T>) => {
beforeRotate?: (context: BeforeRotateContext) => void;
onRotateStart?(
context: RotateStartContext & ExtendedViewContext<T, RotateStartContext>
): void;
onRotateMove?(
context: RotateMoveContext & ExtendedViewContext<T, RotateMoveContext>
): void;
onRotateEnd?(
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
): void;
};
handleSelection?: (
context: Omit<ViewInteractionHandleContext<T>, 'add' | 'delete'>
) => {
selectable?: (
context: SelectableContext &
ExtendedViewContext<T, SelectableContext, boolean>
) => boolean;
onSelect?: (
context: SelectContext &
ExtendedViewContext<T, SelectContext, boolean | void>
) => boolean | void;
};
};
export const GfxViewInteractionIdentifier =
createIdentifier<GfxViewInteractionConfig>('GfxViewInteraction');
export function GfxViewInteractionExtension<
T extends GfxBlockComponent | GfxElementModelView,
>(viewType: string, config: GfxViewInteractionConfig<T>): ExtensionType {
return {
setup(di) {
di.addImpl(
GfxViewInteractionIdentifier(viewType),
() => config as GfxViewInteractionConfig
);
},
};
}

View File

@@ -0,0 +1,108 @@
import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import type { PointerEventState } from '../../event';
import type { GfxController } from '../controller.js';
import type { GfxElementModelView, SupportedEvent } from '../view/view.js';
export class GfxViewEventManager {
private _hoveredElementsStack: GfxElementModelView[] = [];
private _draggingElement: GfxElementModelView | null = null;
private _callInReverseOrder(
callback: (view: GfxElementModelView) => void,
arr = this._hoveredElementsStack
) {
for (let i = arr.length - 1; i >= 0; i--) {
const view = arr[i];
callback(view);
}
}
constructor(private readonly gfx: GfxController) {}
dispatch(eventName: SupportedEvent, evt: PointerEventState) {
if (eventName === 'pointermove') {
this._handlePointerMove(evt);
return false;
} else if (eventName.startsWith('drag')) {
return this._handleDrag(
eventName as 'dragstart' | 'dragend' | 'dragmove',
evt
);
} else {
return last(this._hoveredElementsStack)?.dispatch(eventName, evt);
}
}
private _handleDrag(
evtName: 'dragstart' | 'dragend' | 'dragmove',
_evt: PointerEventState
): boolean {
switch (evtName) {
case 'dragstart': {
if (this._draggingElement) {
this._draggingElement.dispatch('dragend', _evt);
}
this._draggingElement = last(this._hoveredElementsStack) ?? null;
return this._draggingElement?.dispatch('dragstart', _evt) ?? false;
}
case 'dragmove': {
return this._draggingElement?.dispatch('dragmove', _evt) ?? false;
}
case 'dragend': {
const dispatched =
this._draggingElement?.dispatch('dragend', _evt) ?? false;
this._draggingElement = null;
return dispatched;
}
}
}
private _handlePointerMove(_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'],
})
.reduce((pre, model) => {
if (
model.includesPoint(
x,
y,
{
hitThreshold: 10,
responsePadding: [5, 5],
},
this.gfx.std.host
) ||
('externalBound' in model
? model.externalBound?.isPointInBound([x, y])
: false)
) {
const view = this.gfx.view.get(model) as GfxElementModelView | null;
view && pre.push(view);
}
return pre;
}, [] as GfxElementModelView[]);
const currentStackedViews = new Set(this._hoveredElementsStack);
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._hoveredElementsStack = hoveredElmViews;
}
}

View File

@@ -0,0 +1,33 @@
export type { GfxInteractivityContext } from './event.js';
export { InteractivityExtension } from './extension/base.js';
export {
type GfxViewInteractionConfig,
GfxViewInteractionExtension,
GfxViewInteractionIdentifier,
} from './extension/view.js';
export { GfxViewEventManager } from './gfx-view-event-handler.js';
export { InteractivityIdentifier, InteractivityManager } from './manager.js';
export { type ResizeHandle } from './resize/manager.js';
export type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './types/drag.js';
export type {
BoxSelectionContext,
DragEndContext,
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
ResizeConstraint,
ResizeEndContext,
ResizeMoveContext,
ResizeStartContext,
RotateConstraint,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
SelectContext,
} from './types/view.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
import {
Bound,
getCommonBoundWithRotation,
type IBound,
type IPoint,
type IVec,
} from '@blocksuite/global/gfx';
import type { GfxController } from '../..';
import type { GfxModel } from '../../model/model';
export type ResizeHandle =
| 'top-left'
| 'top'
| 'top-right'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left';
export const DEFAULT_HANDLES: ResizeHandle[] = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'left',
'right',
'top',
'bottom',
];
type ReadonlyIBound = Readonly<Required<IBound>>;
export interface OptionResize {
elements: GfxModel[];
handle: ResizeHandle;
lockRatio: boolean;
event: PointerEvent;
onResizeMove: (payload: {
scaleX: number;
scaleY: number;
originalBound: IBound;
handleSign: IPoint;
handlePos: IVec;
currentHandlePos: IVec;
lockRatio: boolean;
}) => { scaleX: number; scaleY: number };
onResizeUpdate: (payload: {
lockRatio: boolean;
scaleX: number;
scaleY: number;
data: {
model: GfxModel;
originalBound: Bound;
newBound: Bound;
lockRatio: boolean;
matrix: DOMMatrix;
}[];
}) => void;
onResizeStart?: (payload: {
handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
onResizeEnd?: (payload: {
handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
}
export type RotateOption = {
elements: GfxModel[];
event: PointerEvent;
onRotateUpdate: (payload: {
delta: number;
data: {
model: GfxModel;
newBound: Bound;
originalBound: Bound;
originalRotate: number;
newRotate: number;
matrix: DOMMatrix;
}[];
}) => void;
onRotateStart?: (payload: { data: { model: GfxModel }[] }) => void;
onRotateEnd?: (payload: { data: { model: GfxModel }[] }) => void;
};
export class ResizeController {
private readonly gfx: GfxController;
get host() {
return this.gfx.std.host;
}
constructor(option: { gfx: GfxController }) {
this.gfx = option.gfx;
}
getCoordsTransform(originalBound: IBound, handle: ResizeHandle) {
const { x: xSign, y: ySign } = this.getHandleSign(handle);
const pivot = new DOMPoint(
originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
originalBound.y + ((-ySign + 1) / 2) * originalBound.h
);
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocalRotatedM = new DOMMatrix()
.translate(-pivot.x, -pivot.y)
.translate(
originalBound.w / 2 + originalBound.x,
originalBound.h / 2 + originalBound.y
)
.rotate(-(originalBound.rotate ?? 0))
.translate(
-(originalBound.w / 2 + originalBound.x),
-(originalBound.h / 2 + originalBound.y)
);
const toLocal = (p: DOMPoint, withRotation: boolean = false) =>
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse());
const handlePos = toModel(
new DOMPoint(originalBound.w * xSign, originalBound.h * ySign)
);
return {
xSign,
ySign,
originalBound,
toLocalM,
toLocalRotatedM,
toLocal,
toModel,
handlePos: [handlePos.x, handlePos.y] as IVec,
};
}
getScaleFromDelta(
transform: ReturnType<ResizeController['getCoordsTransform']>,
delta: { dx: number; dy: number },
handleStartPos: IVec,
lockRatio: boolean
) {
const { originalBound, xSign, ySign, toModel, toLocal } = transform;
const currentPos = toLocal(
new DOMPoint(handleStartPos[0] + delta.dx, handleStartPos[1] + delta.dy),
true
);
let scaleX = xSign ? currentPos.x / (originalBound.w * xSign) : 1;
let scaleY = ySign ? currentPos.y / (originalBound.h * ySign) : 1;
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
const finalHandlePos = toModel(
new DOMPoint(
originalBound.w * xSign * scaleX,
originalBound.h * ySign * scaleY
)
);
return {
scaleX,
scaleY,
handlePos: [finalHandlePos.x, finalHandlePos.y] as IVec,
};
}
getScaleMatrix(
{ scaleX, scaleY }: { scaleX: number; scaleY: number },
lockRatio: boolean
) {
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
return {
scaleX,
scaleY,
scaleM: new DOMMatrix().scaleSelf(scaleX, scaleY),
};
}
startResize(options: OptionResize) {
const {
elements,
handle,
onResizeStart,
onResizeMove,
onResizeUpdate,
onResizeEnd,
event,
} = options;
const originals: ReadonlyIBound[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
h: el.h,
rotate: el.rotate,
}));
const originalBound: IBound =
originals.length > 1
? getCommonBoundWithRotation(originals)
: {
x: originals[0].x,
y: originals[0].y,
w: originals[0].w,
h: originals[0].h,
rotate: originals[0].rotate,
};
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX,
event.clientY,
]);
const transform = this.getCoordsTransform(originalBound, handle);
const handleSign = {
x: transform.xSign,
y: transform.ySign,
};
const onPointerMove = (e: PointerEvent) => {
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
e.clientX,
e.clientY,
]);
let delta = {
dx: currPt[0] - startPt[0],
dy: currPt[1] - startPt[1],
};
const shouldLockRatio =
options.lockRatio || e.shiftKey || elements.length > 1;
const {
scaleX,
scaleY,
handlePos: currentHandlePos,
} = this.getScaleFromDelta(
transform,
delta,
transform.handlePos,
shouldLockRatio
);
const scale = onResizeMove({
scaleX,
scaleY,
originalBound,
handleSign,
handlePos: transform.handlePos,
currentHandlePos,
lockRatio: shouldLockRatio,
});
const scaleInfo = this.getScaleMatrix(scale, shouldLockRatio);
if (elements.length === 1) {
this.resizeSingle(
originals[0],
elements[0],
shouldLockRatio,
transform,
scaleInfo,
onResizeUpdate
);
} else {
this.resizeMulti(
originals,
elements,
transform,
scaleInfo,
onResizeUpdate
);
}
};
onResizeStart?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp);
onResizeEnd?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
};
this.host.addEventListener('pointermove', onPointerMove);
this.host.addEventListener('pointerup', onPointerUp);
}
private resizeSingle(
orig: ReadonlyIBound,
model: GfxModel,
lockRatio: boolean,
transform: ReturnType<typeof ResizeController.prototype.getCoordsTransform>,
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
updateCallback: OptionResize['onResizeUpdate']
) {
const { toLocalM, toLocalRotatedM, toLocal, toModel } = transform;
const { scaleX, scaleY, scaleM } = scale;
const [visualTopLeft, visualBottomRight] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => {
const localP = toLocal(p, false);
const scaledP = localP.matrixTransform(scaleM);
return toModel(scaledP);
});
const center = {
x:
Math.min(visualTopLeft.x, visualBottomRight.x) +
Math.abs(visualBottomRight.x - visualTopLeft.x) / 2,
y:
Math.min(visualTopLeft.y, visualBottomRight.y) +
Math.abs(visualBottomRight.y - visualTopLeft.y) / 2,
};
const restoreM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(-orig.rotate)
.translate(-center.x, -center.y);
// only used to provide the matrix information
const finalM = restoreM
.multiply(toLocalRotatedM.inverse())
.multiply(scaleM)
.multiply(toLocalM);
const [topLeft, bottomRight] = [visualTopLeft, visualBottomRight].map(p => {
return p.matrixTransform(restoreM);
});
updateCallback({
lockRatio,
scaleX,
scaleY,
data: [
{
model: model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(
Math.min(topLeft.x, bottomRight.x),
Math.min(bottomRight.y, topLeft.y),
Math.abs(bottomRight.x - topLeft.x),
Math.abs(bottomRight.y - topLeft.y)
),
lockRatio: lockRatio,
matrix: finalM,
},
],
});
}
private resizeMulti(
originals: ReadonlyIBound[],
elements: GfxModel[],
transform: ReturnType<ResizeController['getCoordsTransform']>,
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
updateCallback: OptionResize['onResizeUpdate']
) {
const { toLocalM } = transform;
const { scaleX, scaleY, scaleM } = scale;
const data = elements.map((model, i) => {
const orig = originals[i];
const finalM = new DOMMatrix()
.multiply(toLocalM.inverse())
.multiply(scaleM)
.multiply(toLocalM);
const [topLeft, bottomRight] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => {
return p.matrixTransform(finalM);
});
const newBound = new Bound(
Math.min(topLeft.x, bottomRight.x),
Math.min(bottomRight.y, topLeft.y),
Math.abs(bottomRight.x - topLeft.x),
Math.abs(bottomRight.y - topLeft.y)
);
return {
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound,
lockRatio: true,
matrix: finalM,
};
});
updateCallback({ lockRatio: true, scaleX, scaleY, data });
}
startRotate(option: RotateOption) {
const { event, elements, onRotateUpdate } = option;
const originals: ReadonlyIBound[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
h: el.h,
rotate: el.rotate,
}));
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX,
event.clientY,
]);
const onPointerMove = (e: PointerEvent) => {
const currentPt = this.gfx.viewport.toModelCoordFromClientCoord([
e.clientX,
e.clientY,
]);
const snap = e.shiftKey;
if (elements.length > 1) {
this.rotateMulti({
origs: originals,
models: elements,
startPt,
currentPt,
snap,
onRotateUpdate,
});
} else {
this.rotateSingle({
orig: originals[0],
model: elements[0],
startPt,
currentPt,
snap,
onRotateUpdate,
});
}
};
const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp);
this.host.removeEventListener('pointercancel', onPointerUp);
option.onRotateEnd?.({ data: elements.map(model => ({ model })) });
};
option.onRotateStart?.({ data: elements.map(model => ({ model })) });
this.host.addEventListener('pointermove', onPointerMove, false);
this.host.addEventListener('pointerup', onPointerUp, false);
this.host.addEventListener('pointercancel', onPointerUp, false);
}
private getNormalizedAngle(y: number, x: number) {
let angle = Math.atan2(y, x);
if (angle < 0) {
angle += 2 * Math.PI;
}
return (angle * 180) / Math.PI;
}
private toNormalizedAngle(angle: number) {
if (angle < 0) {
angle += 360;
}
return Math.round(angle) % 360;
}
private rotateSingle(option: {
orig: ReadonlyIBound;
model: GfxModel;
startPt: IVec;
currentPt: IVec;
snap: boolean;
onRotateUpdate?: RotateOption['onRotateUpdate'];
}) {
const { orig, model, startPt, currentPt, snap, onRotateUpdate } = option;
const center = {
x: orig.x + orig.w / 2,
y: orig.y + orig.h / 2,
};
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
const startAngle = this.getNormalizedAngle(v0.y, v0.x),
endAngle = this.getNormalizedAngle(v1.y, v1.x);
const deltaDeg = endAngle - startAngle;
const rotatedAngle = orig.rotate + deltaDeg;
const targetRotate = this.toNormalizedAngle(
snap
? Math.round((rotatedAngle % 15) / 15) * 15 +
Math.floor(rotatedAngle / 15) * 15
: rotatedAngle
);
// only used to provide the matrix information
const rotateM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(targetRotate - orig.rotate)
.translate(-center.x, -center.y);
onRotateUpdate?.({
delta: deltaDeg,
data: [
{
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(orig.x, orig.y, orig.w, orig.h),
originalRotate: orig.rotate,
newRotate: targetRotate,
matrix: rotateM,
},
],
});
}
private rotateMulti(option: {
origs: ReadonlyIBound[];
models: GfxModel[];
startPt: IVec;
currentPt: IVec;
snap: boolean;
onRotateUpdate?: RotateOption['onRotateUpdate'];
}) {
const { models, startPt, currentPt, onRotateUpdate } = option;
const commonBound = getCommonBoundWithRotation(option.origs);
const center = {
x: commonBound.x + commonBound.w / 2,
y: commonBound.y + commonBound.h / 2,
};
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
const a0 = this.getNormalizedAngle(v0.y, v0.x),
a1 = this.getNormalizedAngle(v1.y, v1.x);
const deltaDeg = a1 - a0;
const rotateM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(deltaDeg)
.translate(-center.x, -center.y);
const toRotatedPoint = (p: DOMPoint) => p.matrixTransform(rotateM);
onRotateUpdate?.({
delta: deltaDeg,
data: models.map((model, index) => {
const orig = option.origs[index];
const center = {
x: orig.x + orig.w / 2,
y: orig.y + orig.h / 2,
};
const visualM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(orig.rotate)
.translate(-center.x, -center.y);
const toVisual = (p: DOMPoint) => p.matrixTransform(visualM);
const [rotatedVisualLeftTop, rotatedVisualBottomRight] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => toRotatedPoint(toVisual(p)));
const newCenter = {
x:
Math.min(rotatedVisualLeftTop.x, rotatedVisualBottomRight.x) +
Math.abs(rotatedVisualBottomRight.x - rotatedVisualLeftTop.x) / 2,
y:
Math.min(rotatedVisualLeftTop.y, rotatedVisualBottomRight.y) +
Math.abs(rotatedVisualBottomRight.y - rotatedVisualLeftTop.y) / 2,
};
const newRotated = this.toNormalizedAngle(orig.rotate + deltaDeg);
const finalM = new DOMMatrix()
.translate(newCenter.x, newCenter.y)
.rotate(-newRotated)
.translate(-newCenter.x, -newCenter.y)
.multiply(rotateM)
.multiply(visualM);
const topLeft = rotatedVisualLeftTop.matrixTransform(
new DOMMatrix()
.translate(newCenter.x, newCenter.y)
.rotate(-newRotated)
.translate(-newCenter.x, -newCenter.y)
);
return {
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(topLeft.x, topLeft.y, orig.w, orig.h),
originalRotate: orig.rotate,
newRotate: newRotated,
matrix: finalM,
};
}),
});
}
private getHandleSign(handle: ResizeHandle) {
switch (handle) {
case 'top-left':
return { x: -1, y: -1 };
case 'top':
return { x: 0, y: -1 };
case 'top-right':
return { x: 1, y: -1 };
case 'right':
return { x: 1, y: 0 };
case 'bottom-right':
return { x: 1, y: 1 };
case 'bottom':
return { x: 0, y: 1 };
case 'bottom-left':
return { x: -1, y: 1 };
case 'left':
return { x: -1, y: 0 };
default:
return { x: 0, y: 0 };
}
}
}

View File

@@ -0,0 +1,7 @@
import type { GfxModel } from '../../model/model';
export type ExtensionElementsCloneContext = {
elements: GfxModel[];
};
export type RequestElementsCloneContext = ExtensionElementsCloneContext;

View 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;

View File

@@ -0,0 +1,41 @@
import type { IBound, IPoint, IVec } from '@blocksuite/global/gfx';
import type { GfxModel } from '../../model/model';
import type { ResizeHandle } from '../resize/manager';
export type ExtensionElementResizeContext = {
elements: GfxModel[];
};
export type ExtensionElementResizeStartContext = {
elements: GfxModel[];
/**
* The position of the handle in the browser coordinate space.
*/
handlePos: IVec;
/**
* The sign (or normal vector) of the handle.
*/
handleSign: IPoint;
handle: ResizeHandle;
};
export type ExtensionElementResizeEndContext =
ExtensionElementResizeStartContext;
export type ExtensionElementResizeMoveContext =
ExtensionElementResizeStartContext & {
scaleX: number;
scaleY: number;
originalBound: IBound;
currentHandlePos: IVec;
lockRatio: boolean;
suggest: (distance: { scaleX: number; scaleY: number }) => void;
};

View File

@@ -0,0 +1,33 @@
import type { GfxModel } from '../../model/model';
export type ExtensionElementSelectContext = {
/**
* The candidate elements for selection.
*/
candidates: GfxModel[];
/**
* The element which is ready to be selected.
*/
target: GfxModel;
/**
* Use to change the target element of selection.
* @param element
* @returns
*/
suggest: (element: {
/**
* The suggested element id
*/
id: string;
/**
* The priority of the suggestion. If there are multiple suggestions coming from different extensions,
* the one with the highest priority will be used.
*
* Default to 0.
*/
priority?: number;
}) => void;
};

View File

@@ -0,0 +1,173 @@
import type { Bound, IBound, IPoint } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../../view/element/gfx-block-component.js';
import type { GfxModel } from '../../model/model.js';
import type { GfxElementModelView } from '../../view/view.js';
import type { ResizeHandle } from '../resize/manager.js';
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 ResizeConstraint = {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
allowedHandlers?: ResizeHandle[];
/**
* Whether to lock the aspect ratio of the element when resizing.
* If the value is an array, it will only lock the aspect ratio when resizing the specified handles.
*/
lockRatio?: boolean | ResizeHandle[];
};
export type BeforeResizeContext = {
/**
* The elements that will be resized
*/
elements: (GfxBlockComponent | GfxElementModelView)[];
/**
* Set the constraint before resize starts.
*/
set: (constraint: ResizeConstraint) => void;
};
export type ResizeStartContext = {
/**
* The handle that is used to resize the element
*/
handle: ResizeHandle;
/**
* The resize constraint.
*/
constraint: Readonly<Required<ResizeConstraint>>;
};
export type ResizeMoveContext = ResizeStartContext & {
/**
* The element bound when resize starts
*/
originalBound: Bound;
newBound: Bound;
/**
* The matrix that used to transform the element.
*/
matrix: DOMMatrix;
lockRatio: boolean;
};
export type ResizeEndContext = ResizeStartContext;
export type RotateConstraint = {
rotatable?: boolean;
};
export type BeforeRotateContext = {
/**
* The elements that will be rotated
*/
elements: (GfxBlockComponent | GfxElementModelView)[];
/**
* Set the constraint before rotate starts.
*/
set: (constraint: RotateConstraint) => void;
};
export type RotateStartContext = {
constraint: Readonly<Required<RotateConstraint>>;
};
export type RotateMoveContext = RotateStartContext & {
newBound: Bound;
originalBound: Bound;
newRotate: number;
originalRotate: number;
matrix: DOMMatrix;
};
export type RotateEndContext = RotateStartContext;
export type SelectableContext = {
/**
* 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;
};
export type SelectContext = SelectableContext & {
/**
* The selected state of the element
*/
selected: boolean;
};
export type BoxSelectionContext = {
box: Readonly<
IBound & {
startX: number;
startY: number;
endX: number;
endY: number;
}
>;
};
export type GfxViewTransformInterface = {
onDragStart: (context: DragStartContext) => void;
onDragMove: (context: DragMoveContext) => void;
onDragEnd: (context: DragEndContext) => void;
/**
* When the element is selected by box selection, return false to prevent the default selection behavior.
*/
onBoxSelected: (context: BoxSelectionContext) => boolean | void;
};

View 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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
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;
/**
* Whether to disable fallback rendering for this element, e.g., during zooming.
* Defaults to false (fallback to placeholder rendering is enabled).
*/
forceFullRender?: boolean;
}
/**
* 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;
}

View File

@@ -0,0 +1,315 @@
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];
get rotate() {
if ('rotate' in this.props) {
return this.props.rotate as number;
}
return 0;
}
set rotate(rotate: number) {
if ('rotate' in this.props) {
this.props.rotate = rotate;
}
}
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.store.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.store, this);
}
unlock() {
unlockElementImpl(this.store, 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>;
}

View File

@@ -0,0 +1,18 @@
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;
export const isPrimitiveModel = (
model: GfxModel
): model is GfxPrimitiveElementModel => {
return model instanceof GfxPrimitiveElementModel;
};

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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>;
};
}

View File

@@ -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.store.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.store.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>;
};
}

View File

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

View File

@@ -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>;
};
}

View File

@@ -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)()
);
});
}

View File

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

View File

@@ -0,0 +1,617 @@
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;
comments?: Record<string, boolean>;
};
export type SerializedElement = Record<string, unknown> & {
type: string;
xywh: SerializedXYWH;
id: string;
index: string;
lockedBySelf?: boolean;
comments?: Record<string, 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() {
const xywh = this.xywh;
if (!this._lastXYWH || xywh !== this._lastXYWH) {
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.store, 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.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.store, 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;
@field()
accessor comments: Record<string, boolean> | undefined = undefined;
}
export abstract class GfxGroupLikeElementModel<
Props extends BaseElementProps = BaseElementProps,
>
extends GfxPrimitiveElementModel<Props>
implements GfxGroupCompatibleInterface
{
private _childIds: string[] = [];
private _xywhDirty = true;
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.store.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(() => {
if (this._xywhDirty || !this._local.has('xywh')) {
this._local.set('xywh', this._getXYWH().serialize());
this._xywhDirty = false;
}
});
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;
});
return bound ?? new Bound(0, 0, 0, 0);
}
invalidateXYWH() {
this._xywhDirty = true;
this._local.delete('deserializedXYWH');
}
refreshXYWH(local: boolean) {
this._mutex(() => {
const oldXYWH =
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
const nextXYWH = this._getXYWH().serialize();
this._xywhDirty = false;
if (oldXYWH === nextXYWH && this._local.has('xywh')) {
return;
}
this._local.set('xywh', nextXYWH);
this._local.delete('deserializedXYWH');
this._onChange({
props: {
xywh: nextXYWH,
},
oldValues: {
xywh: oldXYWH,
},
local,
});
});
}
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.invalidateXYWH();
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);
};
}

View File

@@ -0,0 +1,254 @@
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, GfxModel } 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;
creator: GfxModel | null = null;
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]';
}

View File

@@ -0,0 +1,940 @@
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 { measureOperation } from '../../perf.js';
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> {
private static readonly _groupBoundImpactKeys = new Set([
'xywh',
'rotate',
'hidden',
]);
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 _parentGroupMap = new Map<string, string>();
protected _groupChildIdsMap = new Map<string, string[]>();
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 _collectElementsToDelete(
id: string,
deleteElementIds: Set<string>,
orderedDeleteIds: string[],
deleteBlockIds: Set<string>
) {
if (deleteElementIds.has(id)) {
return;
}
const element = this.getElementById(id);
if (!element) {
return;
}
deleteElementIds.add(id);
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this._collectElementsToDelete(
childId,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
return;
}
if (this.store.hasBlock(childId)) {
deleteBlockIds.add(childId);
}
});
}
orderedDeleteIds.push(id);
}
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 _emitElementUpdated(
model: GfxPrimitiveElementModel,
payload: ElementUpdatedData
) {
if (
isGfxGroupCompatibleModel(model) &&
('childIds' in payload.props || 'childIds' in payload.oldValues)
) {
const oldChildIds = Array.isArray(payload.oldValues['childIds'])
? (payload.oldValues['childIds'] as string[])
: undefined;
this._syncGroupChildrenIndex(model.id, model.childIds, oldChildIds);
}
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.propsUpdated.next({ key });
});
this._refreshParentGroupBoundsForElement(model, payload);
}
private _refreshParentGroupBounds(id: string, local: boolean) {
const group = this.getGroup(id);
if (group instanceof GfxGroupLikeElementModel) {
group.refreshXYWH(local);
}
}
private _refreshParentGroupBoundsForElement(
model: GfxPrimitiveElementModel,
payload: ElementUpdatedData
) {
if (
model instanceof GfxGroupLikeElementModel &&
('childIds' in payload.props || 'childIds' in payload.oldValues)
) {
model.refreshXYWH(payload.local);
return;
}
const affectedKeys = new Set([
...Object.keys(payload.props),
...Object.keys(payload.oldValues),
]);
if (
Array.from(affectedKeys).some(key =>
SurfaceBlockModel._groupBoundImpactKeys.has(key)
)
) {
this._refreshParentGroupBounds(model.id, payload.local);
}
}
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);
this._syncGroupChildrenIndex(model.id, model.childIds, []);
}
};
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
const sameTypeElements = this._elementTypeMap.get(type) || [];
const index = sameTypeElements.indexOf(model);
if (index !== -1) {
sameTypeElements.splice(index, 1);
}
this._parentGroupMap.delete(model.id);
if (isGfxGroupCompatibleModel(model)) {
this._removeGroupFromChildrenIndex(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._emitElementUpdated(model.model, {
...payload,
id,
});
},
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._emitElementUpdated(model.model, {
...payload,
id: key,
});
},
skipFieldInit: true,
}
);
this._elementModels.set(key, model);
});
this._elementModels.forEach(({ mount, model }) => {
addToType(model.type, model);
mount();
});
Object.values(this.store.blocks.peek()).forEach(block => {
if (isGfxGroupCompatibleModel(block.model)) {
this._groupLikeModels.set(block.id, block.model);
this._syncGroupChildrenIndex(block.id, block.model.childIds, []);
}
});
this._rebuildGroupChildrenIndex();
elementsYMap.observe(onElementsMapChange);
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
switch (payload.type) {
case 'add':
if (isGfxGroupCompatibleModel(payload.model)) {
this._groupLikeModels.set(payload.id, payload.model);
this._syncGroupChildrenIndex(
payload.id,
payload.model.childIds,
[]
);
}
if (payload.model instanceof BlockModel) {
this._refreshParentGroupBounds(payload.id, payload.isLocal);
}
break;
case 'delete':
if (isGfxGroupCompatibleModel(payload.model)) {
this._removeGroupFromChildrenIndex(payload.id);
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);
}
}
this._parentGroupMap.delete(payload.id);
break;
case 'update':
if (payload.props.key === 'childElementIds') {
const group = this.store.getBlock(payload.id)?.model;
if (group && isGfxGroupCompatibleModel(group)) {
this._syncGroupChildrenIndex(group.id, group.childIds);
}
}
if (
payload.props.key &&
SurfaceBlockModel._groupBoundImpactKeys.has(payload.props.key)
) {
this._refreshParentGroupBounds(payload.id, payload.isLocal);
}
break;
}
});
this.deleted.subscribe(() => {
elementsYMap.unobserve(onElementsMapChange);
subscription.unsubscribe();
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
});
}
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.store.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];
}
private _rebuildGroupChildrenIndex() {
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
this._groupLikeModels.forEach(group => {
this._syncGroupChildrenIndex(group.id, group.childIds, []);
});
}
private _removeFromParentGroupIfNeeded(
element: GfxModel,
deleteElementIds: Set<string>
) {
const parentGroupId = this._parentGroupMap.get(element.id);
if (parentGroupId && deleteElementIds.has(parentGroupId)) {
return;
}
let parentGroup: GfxGroupModel | null = null;
if (parentGroupId) {
parentGroup = this._groupLikeModels.get(parentGroupId) ?? null;
}
parentGroup = parentGroup ?? this.getGroup(element.id);
if (parentGroup && !deleteElementIds.has(parentGroup.id)) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parentGroup.removeChild(element);
}
}
private _removeGroupFromChildrenIndex(groupId: string) {
const previousChildIds = this._groupChildIdsMap.get(groupId) ?? [];
previousChildIds.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
this._groupChildIdsMap.delete(groupId);
}
private _syncGroupChildrenIndex(
groupId: string,
nextChildIds: string[],
previousChildIds?: string[]
) {
const prev = previousChildIds ?? this._groupChildIdsMap.get(groupId) ?? [];
prev.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
nextChildIds.forEach(childId => {
this._parentGroupMap.set(childId, groupId);
});
this._groupChildIdsMap.set(groupId, [...nextChildIds]);
}
addElement<T extends object = Record<string, unknown>>(
props: Partial<T> & { type: string }
) {
if (this.store.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._emitElementUpdated(elementModel.model, {
...payload,
id,
});
},
});
this._elementModels.set(id, elementModel);
this.store.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.store.readonly) {
throw new Error('Cannot remove element in readonly mode');
}
if (!this.hasElementById(id)) {
return;
}
measureOperation('edgeless:delete-element', () => {
const deleteElementIds = new Set<string>();
const orderedDeleteIds: string[] = [];
const deleteBlockIds = new Set<string>();
this._collectElementsToDelete(
id,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
if (orderedDeleteIds.length === 0) {
return;
}
this.store.transact(() => {
orderedDeleteIds.forEach(elementId => {
const element = this.getElementById(elementId);
if (!element) {
return;
}
this._removeFromParentGroupIfNeeded(element, deleteElementIds);
this.elements.getValue()!.delete(elementId);
});
deleteBlockIds.forEach(blockId => {
const block = this.store.getBlock(blockId)?.model;
if (!block) {
return;
}
this._removeFromParentGroupIfNeeded(
block as GfxModel,
deleteElementIds
);
this.store.deleteBlock(block);
});
});
});
}
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 {
const id = typeof elem === 'string' ? elem : elem.id;
const parentGroupId = this._parentGroupMap.get(id);
if (parentGroupId) {
const group = this._groupLikeModels.get(parentGroupId);
if (group) {
return group;
}
this._parentGroupMap.delete(id);
}
const model =
typeof elem === 'string'
? ((this.getElementById(elem) ??
this.store.getBlock(elem)?.model) as GfxModel)
: elem;
if (!model) return null;
assertType<GfxModel>(model);
for (const group of this._groupLikeModels.values()) {
if (group.hasChild(model)) {
this._parentGroupMap.set(id, group.id);
return group;
}
}
return null;
}
/**
* Get all groups in the group chain. The last group is the top level group.
* @param id
* @returns
*/
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.store.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.store.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.store.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;
});
});
}
}

View File

@@ -0,0 +1,31 @@
let opMeasureSeq = 0;
/**
* Measure operation cost via Performance API when available.
*
* Marks are always cleared, while measure entries are intentionally retained
* so callers can inspect them from Performance tools.
*/
export const measureOperation = <T>(name: string, fn: () => T): T => {
if (
typeof performance === 'undefined' ||
typeof performance.mark !== 'function' ||
typeof performance.measure !== 'function'
) {
return fn();
}
const operationId = opMeasureSeq++;
const startMark = `${name}:${operationId}:start`;
const endMark = `${name}:${operationId}:end`;
performance.mark(startMark);
try {
return fn();
} finally {
performance.mark(endMark);
performance.measure(name, startMark, endMark);
performance.clearMarks(startMark);
performance.clearMarks(endMark);
}
};

View File

@@ -0,0 +1,76 @@
export interface RafCoalescer<T> {
cancel: () => void;
flush: () => void;
schedule: (payload: T) => void;
}
type FrameScheduler = (callback: FrameRequestCallback) => number;
type FrameCanceller = (id: number) => void;
const getFrameScheduler = (): FrameScheduler => {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame;
}
return callback => {
return globalThis.setTimeout(() => {
callback(
typeof performance !== 'undefined' ? performance.now() : Date.now()
);
}, 16) as unknown as number;
};
};
const getFrameCanceller = (): FrameCanceller => {
if (typeof cancelAnimationFrame === 'function') {
return cancelAnimationFrame;
}
return id => globalThis.clearTimeout(id);
};
/**
* Coalesce high-frequency updates and only process the latest payload in one frame.
*/
export const createRafCoalescer = <T>(
apply: (payload: T) => void
): RafCoalescer<T> => {
const scheduleFrame = getFrameScheduler();
const cancelFrame = getFrameCanceller();
let pendingPayload: T | undefined;
let hasPendingPayload = false;
let rafId: number | null = null;
const run = () => {
rafId = null;
if (!hasPendingPayload) return;
const payload = pendingPayload as T;
pendingPayload = undefined;
hasPendingPayload = false;
apply(payload);
};
return {
schedule(payload: T) {
pendingPayload = payload;
hasPendingPayload = true;
if (rafId !== null) return;
rafId = scheduleFrame(run);
},
flush() {
if (rafId !== null) cancelFrame(rafId);
run();
},
cancel() {
if (rafId !== null) {
cancelFrame(rafId);
rafId = null;
}
pendingPayload = undefined;
hasPendingPayload = false;
},
};
};

View File

@@ -0,0 +1,408 @@
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(modelX: number, modelY: number) {
const selected = this.selectedElements;
if (!selected.length) return false;
const commonBound = getCommonBoundWithRotation(selected);
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;
}
}

View 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());
}
});
}
}

View File

@@ -0,0 +1,635 @@
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 { computed, Signal } from '@preact/signals-core';
import { Subject, type Subscription } from 'rxjs';
import type { PointerEventState } from '../../event/index.js';
import type { GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import {
type BaseTool,
ToolIdentifier,
type ToolOptions,
type ToolOptionWithType,
type ToolType,
} from './tool.js';
type BuiltInHookEvent<T> = {
data: T;
preventDefault(): void;
};
type BuiltInEventMap = {
beforeToolUpdate: BuiltInHookEvent<{
toolName: string;
}>;
toolUpdate: BuiltInHookEvent<{ toolName: string }>;
};
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;
}
type AreaBound = IBound & {
startX: number;
startY: number;
endX: number;
endY: number;
};
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<ToolOptionWithType>({});
private readonly _tools = new Map<string, BaseTool>();
readonly currentToolName$ = new Signal<string>();
readonly dragging$ = new Signal<boolean>(false);
/**
* The dragging area in browser coordinates space.
*
* This is similar to `draggingViewArea$`, but if the viewport is changed during dragging,
* it will be reflected in this area.
*/
readonly draggingViewportArea$ = computed(() => {
const compute = (modelArea: AreaBound) => {
const [viewStartX, viewStartY] = this.gfx.viewport.toViewCoord(
modelArea.x,
modelArea.y
);
const [viewEndX, viewEndY] = this.gfx.viewport.toViewCoord(
modelArea.x + modelArea.w,
modelArea.y + modelArea.h
);
return {
startX: viewStartX,
startY: viewStartY,
endX: viewEndX,
endY: viewEndY,
x: Math.min(viewStartX, viewEndX),
y: Math.min(viewStartY, viewEndY),
w: Math.abs(viewStartX - viewEndX),
h: Math.abs(viewStartY - viewEndY),
};
};
return compute(this.draggingArea$.value);
});
/**
* The dragging area in browser coordinates 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 in browser coordinates space.
*/
readonly lastMouseViewPos$ = new Signal<IPoint>({
x: 0,
y: 0,
});
/**
* The last mouse position in model coordinates space.
*/
readonly lastMousePos$ = computed(() => {
const [x, y] = this.gfx.viewport.toModelCoord(
this.lastMouseViewPos$.value.x,
this.lastMouseViewPos$.value.y
);
return {
x,
y,
};
});
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() {
return self._toolOption$.peek();
},
get value() {
return self._toolOption$.value;
},
};
}
/**
* The area that is being dragged.
* The coordinates are in model space.
*/
readonly draggingArea$ = new Signal<
IBound & {
startX: number;
startY: number;
endX: number;
endY: number;
}
>({
startX: 0,
startY: 0,
endX: 0,
endY: 0,
x: 0,
y: 0,
w: 0,
h: 0,
});
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;
let viewportSub: Subscription | 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();
}
const [modelX, modelY] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
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.draggingArea$.value = {
startX: modelX,
startY: modelY,
endX: modelX,
endY: modelY,
x: modelX,
y: modelY,
w: 0,
h: 0,
};
this.lastMouseViewPos$.value = {
x: evt.x,
y: evt.y,
};
viewportSub?.unsubscribe();
viewportSub = this.gfx.viewport.viewportUpdated.subscribe(() => {
const lastPost = this.lastMouseViewPos$.peek();
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
lastPost.x,
lastPost.y
);
const original = this.draggingArea$.peek();
this.draggingArea$.value = {
...this.draggingArea$.peek(),
x: Math.min(modelX, original.startX),
y: Math.min(modelY, original.startY),
w: Math.abs(modelX - original.startX),
h: Math.abs(modelY - original.startY),
endX: modelX,
endY: modelY,
};
});
// 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,
};
const [modelX, modelY] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
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,
};
this.draggingArea$.value = {
...this.draggingArea$.peek(),
w: Math.abs(modelX - draggingStart.x),
h: Math.abs(modelY - draggingStart.y),
x: Math.min(modelX, draggingStart.x),
y: Math.min(modelY, draggingStart.y),
endX: modelX,
endY: modelY,
};
this.lastMouseViewPos$.value = {
x: evt.x,
y: 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);
}
viewportSub?.unsubscribe();
viewportSub = null;
dragContext = null;
this.draggingViewArea$.value = {
x: 0,
y: 0,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
w: 0,
h: 0,
};
this.draggingArea$.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.lastMouseViewPos$.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 = <T extends BaseTool>(type: ToolType<T>): T => {
const instance = this._tools.get(type.toolName) as T | undefined;
if (!instance) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
`Trying to get tool "${type.toolName}" is not registered`
);
}
return instance;
};
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 = <T extends BaseTool>(
toolType: ToolType<T>,
options?: ToolOptions<T>
): void => {
const toolNameStr = toolType.toolName;
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 = options ?? {};
this._toolOption$.value = {
toolType,
options: currentTool.activatedOption,
};
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;
}
}

View 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 type ToolType<T extends BaseTool = BaseTool> = {
new (gfx: GfxController): T;
toolName: string;
};
export type ToolOptions<T extends BaseTool> =
T extends BaseTool<infer O> ? O : never;
export type ToolOptionWithType<T extends BaseTool = BaseTool> = {
toolType?: ToolType<T>;
options?: ToolOptions<T>;
};

View 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;
}
}

View File

@@ -0,0 +1,225 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { type Bound, type 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 { GfxController } from '../index.js';
import type {
BoxSelectionContext,
DragEndContext,
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
} from '../interactivity/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;
dragstart: PointerEventState;
dragmove: PointerEventState;
dragend: 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);
}
/**
* Dispatches an event to the view.
* @param event
* @param evt
* @returns Whether the event view has any handlers for the event.
*/
dispatch<K extends keyof EventsHandlerMap>(
event: K,
evt: EventsHandlerMap[K]
) {
const handlers = this._handlers.get(event);
if (handlers?.length) {
handlers.forEach(callback => callback(evt));
return true;
}
return false;
}
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();
}
onBoxSelected(_: BoxSelectionContext): boolean | void {}
/**
* 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) {}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More