mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 08:36:22 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
type SelectionConstructor<T = unknown> = {
|
||||
type: string;
|
||||
group: string;
|
||||
new (...args: unknown[]): T;
|
||||
};
|
||||
|
||||
export type BaseSelectionOptions = {
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
export abstract class BaseSelection {
|
||||
static readonly group: string;
|
||||
|
||||
static readonly type: string;
|
||||
|
||||
readonly blockId: string;
|
||||
|
||||
get group(): string {
|
||||
return (this.constructor as SelectionConstructor).group;
|
||||
}
|
||||
|
||||
get type(): BlockSuite.SelectionType {
|
||||
return (this.constructor as SelectionConstructor)
|
||||
.type as BlockSuite.SelectionType;
|
||||
}
|
||||
|
||||
constructor({ blockId }: BaseSelectionOptions) {
|
||||
this.blockId = blockId;
|
||||
}
|
||||
|
||||
static fromJSON(_: Record<string, unknown>): BaseSelection {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.SelectionError,
|
||||
'You must override this method'
|
||||
);
|
||||
}
|
||||
|
||||
abstract equals(other: BaseSelection): boolean;
|
||||
|
||||
is<T extends BlockSuite.SelectionType>(
|
||||
type: T
|
||||
): this is BlockSuite.SelectionInstance[T] {
|
||||
return this.type === type;
|
||||
}
|
||||
|
||||
abstract toJSON(): Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
BlockSelection,
|
||||
CursorSelection,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from './variants/index.js';
|
||||
|
||||
export * from './base.js';
|
||||
export * from './manager.js';
|
||||
export * from './variants/index.js';
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface Selection {
|
||||
block: typeof BlockSelection;
|
||||
cursor: typeof CursorSelection;
|
||||
surface: typeof SurfaceSelection;
|
||||
text: typeof TextSelection;
|
||||
}
|
||||
|
||||
type SelectionType = keyof Selection;
|
||||
|
||||
type SelectionInstance = {
|
||||
[P in SelectionType]: InstanceType<Selection[P]>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { DisposableGroup, Slot } from '@blocksuite/global/utils';
|
||||
import { nanoid, type StackItem } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { SelectionIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
import type { BaseSelection } from './base.js';
|
||||
|
||||
export interface SelectionConstructor {
|
||||
type: string;
|
||||
|
||||
new (...args: any[]): BaseSelection;
|
||||
fromJSON(json: Record<string, unknown>): BaseSelection;
|
||||
}
|
||||
|
||||
export class SelectionManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'selectionManager';
|
||||
|
||||
private _id: string;
|
||||
|
||||
private _itemAdded = (event: { stackItem: StackItem }) => {
|
||||
event.stackItem.meta.set('selection-state', this.value);
|
||||
};
|
||||
|
||||
private _itemPopped = (event: { stackItem: StackItem }) => {
|
||||
const selection = event.stackItem.meta.get('selection-state');
|
||||
if (selection) {
|
||||
this.set(selection as BaseSelection[]);
|
||||
}
|
||||
};
|
||||
|
||||
private _jsonToSelection = (json: Record<string, unknown>) => {
|
||||
const ctor = this._selectionConstructors[json.type as string];
|
||||
if (!ctor) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.SelectionError,
|
||||
`Unknown selection type: ${json.type}`
|
||||
);
|
||||
}
|
||||
return ctor.fromJSON(json);
|
||||
};
|
||||
|
||||
private _remoteSelections = signal<Map<number, BaseSelection[]>>(new Map());
|
||||
|
||||
private _selectionConstructors: Record<string, SelectionConstructor> = {};
|
||||
|
||||
private _selections = signal<BaseSelection[]>([]);
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
slots = {
|
||||
changed: new Slot<BaseSelection[]>(),
|
||||
remoteChanged: new Slot<Map<number, BaseSelection[]>>(),
|
||||
};
|
||||
|
||||
private get _store() {
|
||||
return this.std.collection.awarenessStore;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get remoteSelections() {
|
||||
return this._remoteSelections.value;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._selections.value;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
this._id = `${this.std.doc.blockCollection.id}:${nanoid()}`;
|
||||
this._setupDefaultSelections();
|
||||
this._store.awareness.on(
|
||||
'change',
|
||||
(change: { updated: number[]; added: number[]; removed: number[] }) => {
|
||||
const all = change.updated.concat(change.added).concat(change.removed);
|
||||
const localClientID = this._store.awareness.clientID;
|
||||
const exceptLocal = all.filter(id => id !== localClientID);
|
||||
const hasLocal = all.includes(localClientID);
|
||||
if (hasLocal) {
|
||||
const localSelectionJson = this._store.getLocalSelection(this.id);
|
||||
const localSelection = localSelectionJson.map(json => {
|
||||
return this._jsonToSelection(json);
|
||||
});
|
||||
this._selections.value = localSelection;
|
||||
}
|
||||
|
||||
// Only consider remote selections from other clients
|
||||
if (exceptLocal.length > 0) {
|
||||
const map = new Map<number, BaseSelection[]>();
|
||||
this._store.getStates().forEach((state, id) => {
|
||||
if (id === this._store.awareness.clientID) return;
|
||||
// selection id starts with the same block collection id from others clients would be considered as remote selections
|
||||
const selection = Object.entries(state.selectionV2)
|
||||
.filter(([key]) =>
|
||||
key.startsWith(this.std.doc.blockCollection.id)
|
||||
)
|
||||
.flatMap(([_, selection]) => selection);
|
||||
|
||||
const selections = selection
|
||||
.map(json => {
|
||||
try {
|
||||
return this._jsonToSelection(json);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Parse remote selection failed:',
|
||||
id,
|
||||
json,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((sel): sel is BaseSelection => !!sel);
|
||||
|
||||
map.set(id, selections);
|
||||
});
|
||||
this._remoteSelections.value = map;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _setupDefaultSelections() {
|
||||
this.std.provider.getAll(SelectionIdentifier).forEach(ctor => {
|
||||
this.register(ctor);
|
||||
});
|
||||
}
|
||||
|
||||
clear(types?: string[]) {
|
||||
if (types) {
|
||||
const values = this.value.filter(
|
||||
selection => !types.includes(selection.type)
|
||||
);
|
||||
this.set(values);
|
||||
} else {
|
||||
this.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
create<T extends BlockSuite.SelectionType>(
|
||||
type: T,
|
||||
...args: ConstructorParameters<BlockSuite.Selection[T]>
|
||||
): BlockSuite.SelectionInstance[T] {
|
||||
const ctor = this._selectionConstructors[type];
|
||||
if (!ctor) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.SelectionError,
|
||||
`Unknown selection type: ${type}`
|
||||
);
|
||||
}
|
||||
return new ctor(...args) as BlockSuite.SelectionInstance[T];
|
||||
}
|
||||
|
||||
dispose() {
|
||||
Object.values(this.slots).forEach(slot => slot.dispose());
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
filter<T extends BlockSuite.SelectionType>(type: T) {
|
||||
return this.filter$(type).value;
|
||||
}
|
||||
|
||||
filter$<T extends BlockSuite.SelectionType>(type: T) {
|
||||
return computed(() =>
|
||||
this.value.filter((sel): sel is BlockSuite.SelectionInstance[T] =>
|
||||
sel.is(type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
find<T extends BlockSuite.SelectionType>(type: T) {
|
||||
return this.find$(type).value;
|
||||
}
|
||||
|
||||
find$<T extends BlockSuite.SelectionType>(type: T) {
|
||||
return computed(() =>
|
||||
this.value.find((sel): sel is BlockSuite.SelectionInstance[T] =>
|
||||
sel.is(type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fromJSON(json: Record<string, unknown>[]) {
|
||||
const selections = json.map(json => {
|
||||
return this._jsonToSelection(json);
|
||||
});
|
||||
return this.set(selections);
|
||||
}
|
||||
|
||||
getGroup(group: string) {
|
||||
return this.value.filter(s => s.group === group);
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
if (this.disposables.disposed) {
|
||||
this.disposables = new DisposableGroup();
|
||||
}
|
||||
this.std.doc.history.on('stack-item-added', this._itemAdded);
|
||||
this.std.doc.history.on('stack-item-popped', this._itemPopped);
|
||||
this.disposables.add(
|
||||
this._store.slots.update.on(({ id }) => {
|
||||
if (id === this._store.awareness.clientID) {
|
||||
return;
|
||||
}
|
||||
this.slots.remoteChanged.emit(this.remoteSelections);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register(ctor: SelectionConstructor | SelectionConstructor[]) {
|
||||
[ctor].flat().forEach(ctor => {
|
||||
this._selectionConstructors[ctor.type] = ctor;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
set(selections: BaseSelection[]) {
|
||||
this._store.setLocalSelection(
|
||||
this.id,
|
||||
selections.map(s => s.toJSON())
|
||||
);
|
||||
this.slots.changed.emit(selections);
|
||||
}
|
||||
|
||||
setGroup(group: string, selections: BaseSelection[]) {
|
||||
const current = this.value.filter(s => s.group !== group);
|
||||
this.set([...current, ...selections]);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.std.doc.history.off('stack-item-added', this._itemAdded);
|
||||
this.std.doc.history.off('stack-item-popped', this._itemPopped);
|
||||
this.slots.changed.dispose();
|
||||
this.disposables.dispose();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
update(fn: (currentSelections: BaseSelection[]) => BaseSelection[]) {
|
||||
const selections = fn(this.value);
|
||||
this.set(selections);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
const BlockSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
});
|
||||
|
||||
export class BlockSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'block';
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): BlockSelection {
|
||||
const result = BlockSelectionSchema.parse(json);
|
||||
return new BlockSelection(result);
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (other instanceof BlockSelection) {
|
||||
return this.blockId === other.blockId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'block',
|
||||
blockId: this.blockId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockSelectionExtension = SelectionExtension(BlockSelection);
|
||||
@@ -0,0 +1,47 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
const CursorSelectionSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
});
|
||||
|
||||
export class CursorSelection extends BaseSelection {
|
||||
static override group = 'gfx';
|
||||
|
||||
static override type = 'cursor';
|
||||
|
||||
readonly x: number;
|
||||
|
||||
readonly y: number;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
super({ blockId: '[gfx-cursor]' });
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): CursorSelection {
|
||||
const { x, y } = CursorSelectionSchema.parse(json);
|
||||
return new CursorSelection(x, y);
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (other instanceof CursorSelection) {
|
||||
return this.x === other.x && this.y === other.y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'cursor',
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CursorSelectionExtension = SelectionExtension(CursorSelection);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './block.js';
|
||||
export * from './cursor.js';
|
||||
export * from './surface.js';
|
||||
export * from './text.js';
|
||||
@@ -0,0 +1,72 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
const SurfaceSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
elements: z.array(z.string()),
|
||||
editing: z.boolean(),
|
||||
inoperable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export class SurfaceSelection extends BaseSelection {
|
||||
static override group = 'gfx';
|
||||
|
||||
static override type = 'surface';
|
||||
|
||||
readonly editing: boolean;
|
||||
|
||||
readonly elements: string[];
|
||||
|
||||
readonly inoperable: boolean;
|
||||
|
||||
constructor(
|
||||
blockId: string,
|
||||
elements: string[],
|
||||
editing: boolean,
|
||||
inoperable = false
|
||||
) {
|
||||
super({ blockId });
|
||||
|
||||
this.elements = elements;
|
||||
this.editing = editing;
|
||||
this.inoperable = inoperable;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): SurfaceSelection {
|
||||
const { blockId, elements, editing, inoperable } =
|
||||
SurfaceSelectionSchema.parse(json);
|
||||
return new SurfaceSelection(blockId, elements, editing, inoperable);
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (other instanceof SurfaceSelection) {
|
||||
return (
|
||||
this.blockId === other.blockId &&
|
||||
this.editing === other.editing &&
|
||||
this.inoperable === other.inoperable &&
|
||||
this.elements.length === other.elements.length &&
|
||||
this.elements.every((id, idx) => id === other.elements[idx])
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.elements.length === 0 && !this.editing;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'surface',
|
||||
blockId: this.blockId,
|
||||
elements: this.elements,
|
||||
editing: this.editing,
|
||||
inoperable: this.inoperable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const SurfaceSelectionExtension = SelectionExtension(SurfaceSelection);
|
||||
@@ -0,0 +1,117 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
export type TextRangePoint = {
|
||||
blockId: string;
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
export type TextSelectionProps = {
|
||||
from: TextRangePoint;
|
||||
to: TextRangePoint | null;
|
||||
reverse?: boolean;
|
||||
};
|
||||
|
||||
const TextSelectionSchema = z.object({
|
||||
from: z.object({
|
||||
blockId: z.string(),
|
||||
index: z.number(),
|
||||
length: z.number(),
|
||||
}),
|
||||
to: z
|
||||
.object({
|
||||
blockId: z.string(),
|
||||
index: z.number(),
|
||||
length: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
// The `optional()` is for backward compatibility,
|
||||
// since `reverse` may not exist in remote selection.
|
||||
reverse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export class TextSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'text';
|
||||
|
||||
from: TextRangePoint;
|
||||
|
||||
reverse: boolean;
|
||||
|
||||
to: TextRangePoint | null;
|
||||
|
||||
get end(): TextRangePoint {
|
||||
return this.reverse ? this.from : (this.to ?? this.from);
|
||||
}
|
||||
|
||||
get start(): TextRangePoint {
|
||||
return this.reverse ? (this.to ?? this.from) : this.from;
|
||||
}
|
||||
|
||||
constructor({ from, to, reverse }: TextSelectionProps) {
|
||||
super({
|
||||
blockId: from.blockId,
|
||||
});
|
||||
this.from = from;
|
||||
|
||||
this.to = this._equalPoint(from, to) ? null : to;
|
||||
|
||||
this.reverse = !!reverse;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): TextSelection {
|
||||
const result = TextSelectionSchema.parse(json);
|
||||
return new TextSelection(result);
|
||||
}
|
||||
|
||||
private _equalPoint(
|
||||
a: TextRangePoint | null,
|
||||
b: TextRangePoint | null
|
||||
): boolean {
|
||||
if (a && b) {
|
||||
return (
|
||||
a.blockId === b.blockId && a.index === b.index && a.length === b.length
|
||||
);
|
||||
}
|
||||
|
||||
return a === b;
|
||||
}
|
||||
|
||||
empty(): boolean {
|
||||
return !!this.to;
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (other instanceof TextSelection) {
|
||||
return (
|
||||
this.blockId === other.blockId &&
|
||||
this._equalPoint(other.from, this.from) &&
|
||||
this._equalPoint(other.to, this.to)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isCollapsed(): boolean {
|
||||
return this.to === null && this.from.length === 0;
|
||||
}
|
||||
|
||||
isInSameBlock(): boolean {
|
||||
return this.to === null || this.from.blockId === this.to.blockId;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'text',
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
reverse: this.reverse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TextSelectionExtension = SelectionExtension(TextSelection);
|
||||
Reference in New Issue
Block a user