mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat(editor): selection as store extension (#9605)
This commit is contained in:
@@ -4,7 +4,6 @@ export * from './config.js';
|
||||
export * from './flavour.js';
|
||||
export * from './keymap.js';
|
||||
export * from './lifecycle-watcher.js';
|
||||
export * from './selection.js';
|
||||
export * from './service.js';
|
||||
export * from './service-watcher.js';
|
||||
export * from './widget-view-map.js';
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { SelectionIdentifier } from '../identifier.js';
|
||||
import type { SelectionConstructor } from '../selection/index.js';
|
||||
|
||||
export function SelectionExtension(
|
||||
selectionCtor: SelectionConstructor
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(SelectionIdentifier(selectionCtor.type), () => selectionCtor);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { Command } from './command/index.js';
|
||||
import type { EventOptions, UIEventHandler } from './event/index.js';
|
||||
import type { BlockService, LifeCycleWatcher } from './extension/index.js';
|
||||
import type { BlockStdScope } from './scope/index.js';
|
||||
import type { SelectionConstructor } from './selection/index.js';
|
||||
import type { BlockViewType, WidgetViewMapType } from './spec/type.js';
|
||||
|
||||
export const BlockServiceIdentifier =
|
||||
@@ -33,6 +32,3 @@ export const KeymapIdentifier = createIdentifier<{
|
||||
getter: (std: BlockStdScope) => Record<string, UIEventHandler>;
|
||||
options?: EventOptions;
|
||||
}>('Keymap');
|
||||
|
||||
export const SelectionIdentifier =
|
||||
createIdentifier<SelectionConstructor>('Selection');
|
||||
|
||||
@@ -84,18 +84,21 @@ export const getInlineRangeProvider: (
|
||||
}
|
||||
};
|
||||
const inlineRange$: InlineRangeProvider['inlineRange$'] = signal(null);
|
||||
selectionManager.slots.changed.on(selections => {
|
||||
const textSelection = selections.find(s => s.type === 'text') as
|
||||
| TextSelection
|
||||
| undefined;
|
||||
const range = rangeManager.value;
|
||||
if (!range || !textSelection) {
|
||||
inlineRange$.value = null;
|
||||
return;
|
||||
}
|
||||
const inlineRange = calculateInlineRange(range, textSelection);
|
||||
inlineRange$.value = inlineRange;
|
||||
});
|
||||
|
||||
editorHost.disposables.add(
|
||||
selectionManager.slots.changed.on(selections => {
|
||||
const textSelection = selections.find(s => s.type === 'text') as
|
||||
| TextSelection
|
||||
| undefined;
|
||||
const range = rangeManager.value;
|
||||
if (!range || !textSelection) {
|
||||
inlineRange$.value = null;
|
||||
return;
|
||||
}
|
||||
const inlineRange = calculateInlineRange(range, textSelection);
|
||||
inlineRange$.value = inlineRange;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
setInlineRange,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { throttle } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { type BaseSelection, TextSelection } from '../selection/index.js';
|
||||
import { TextSelection } from '../selection/index.js';
|
||||
import type { BlockComponent } from '../view/element/block-component.js';
|
||||
import { BLOCK_ID_ATTR } from '../view/index.js';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from './consts.js';
|
||||
@@ -247,6 +247,9 @@ export class RangeBinding {
|
||||
};
|
||||
|
||||
private readonly _onStdSelectionChanged = (selections: BaseSelection[]) => {
|
||||
const closestHost = document.activeElement?.closest('editor-host');
|
||||
if (closestHost && closestHost !== this.host) return;
|
||||
|
||||
const text =
|
||||
selections.find((selection): selection is TextSelection =>
|
||||
selection.is(TextSelection)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Job,
|
||||
type JobMiddleware,
|
||||
type Store,
|
||||
StoreSelectionExtension,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import { Clipboard } from '../clipboard/index.js';
|
||||
@@ -23,13 +24,6 @@ import {
|
||||
StdIdentifier,
|
||||
} from '../identifier.js';
|
||||
import { RangeManager } from '../range/index.js';
|
||||
import {
|
||||
BlockSelectionExtension,
|
||||
CursorSelectionExtension,
|
||||
SelectionManager,
|
||||
SurfaceSelectionExtension,
|
||||
TextSelectionExtension,
|
||||
} from '../selection/index.js';
|
||||
import { ServiceManager } from '../service/index.js';
|
||||
import { EditorHost } from '../view/element/index.js';
|
||||
import { ViewStore } from '../view/view-store.js';
|
||||
@@ -43,15 +37,10 @@ const internalExtensions = [
|
||||
ServiceManager,
|
||||
CommandManager,
|
||||
UIEventDispatcher,
|
||||
SelectionManager,
|
||||
RangeManager,
|
||||
ViewStore,
|
||||
Clipboard,
|
||||
GfxController,
|
||||
BlockSelectionExtension,
|
||||
TextSelectionExtension,
|
||||
SurfaceSelectionExtension,
|
||||
CursorSelectionExtension,
|
||||
GfxSelectionManager,
|
||||
SurfaceMiddlewareExtension,
|
||||
ViewManager,
|
||||
@@ -107,7 +96,7 @@ export class BlockStdScope {
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.get(SelectionManager);
|
||||
return this.get(StoreSelectionExtension);
|
||||
}
|
||||
|
||||
get view() {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import type { SelectionConstructor } from './manager';
|
||||
|
||||
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(): string {
|
||||
return (this.constructor as SelectionConstructor).type as string;
|
||||
}
|
||||
|
||||
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 SelectionConstructor>(
|
||||
type: T
|
||||
): this is T extends SelectionConstructor<infer U> ? U : never {
|
||||
return this.type === type.type;
|
||||
}
|
||||
|
||||
abstract toJSON(): Record<string, unknown>;
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export * from './base.js';
|
||||
export * from './manager.js';
|
||||
export * from './variants/index.js';
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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<T extends BaseSelection = BaseSelection> {
|
||||
type: string;
|
||||
group: string;
|
||||
|
||||
new (...args: any[]): T;
|
||||
fromJSON(json: Record<string, unknown>): T;
|
||||
}
|
||||
|
||||
export class SelectionManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'selectionManager';
|
||||
|
||||
private readonly _id: string;
|
||||
|
||||
private readonly _itemAdded = (event: { stackItem: StackItem }) => {
|
||||
event.stackItem.meta.set('selection-state', this.value);
|
||||
};
|
||||
|
||||
private readonly _itemPopped = (event: { stackItem: StackItem }) => {
|
||||
const selection = event.stackItem.meta.get('selection-state');
|
||||
if (selection) {
|
||||
this.set(selection as BaseSelection[]);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _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 readonly _remoteSelections = signal<Map<number, BaseSelection[]>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
private _selectionConstructors: Record<string, SelectionConstructor> = {};
|
||||
|
||||
private readonly _selections = signal<BaseSelection[]>([]);
|
||||
|
||||
disposables = new DisposableGroup();
|
||||
|
||||
slots = {
|
||||
changed: new Slot<BaseSelection[]>(),
|
||||
remoteChanged: new Slot<Map<number, BaseSelection[]>>(),
|
||||
};
|
||||
|
||||
private get _store() {
|
||||
return this.std.workspace.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.store.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.store.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 SelectionConstructor>(
|
||||
Type: T,
|
||||
...args: ConstructorParameters<T>
|
||||
): InstanceType<T> {
|
||||
return new Type(...args) as InstanceType<T>;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
Object.values(this.slots).forEach(slot => slot.dispose());
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
filter<T extends SelectionConstructor>(type: T) {
|
||||
return this.filter$(type).value;
|
||||
}
|
||||
|
||||
filter$<T extends SelectionConstructor>(type: T) {
|
||||
return computed(() =>
|
||||
this.value.filter((sel): sel is InstanceType<T> => sel.is(type))
|
||||
);
|
||||
}
|
||||
|
||||
find<T extends SelectionConstructor>(type: T) {
|
||||
return this.find$(type).value;
|
||||
}
|
||||
|
||||
find$<T extends SelectionConstructor>(type: T) {
|
||||
return computed(() =>
|
||||
this.value.find((sel): sel is InstanceType<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.store.history.on('stack-item-added', this._itemAdded);
|
||||
this.std.store.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.store.history.off('stack-item-added', this._itemAdded);
|
||||
this.std.store.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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
const BlockSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
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(),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
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()),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import z from 'zod';
|
||||
|
||||
import { SelectionExtension } from '../../extension/selection.js';
|
||||
import { BaseSelection } from '../base.js';
|
||||
|
||||
export type TextRangePoint = {
|
||||
blockId: string;
|
||||
index: number;
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
handleError,
|
||||
} from '@blocksuite/global/exceptions';
|
||||
import { SignalWatcher, Slot, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { type BlockModel, Store } from '@blocksuite/store';
|
||||
import {
|
||||
type BlockModel,
|
||||
Store,
|
||||
type StoreSelectionExtension,
|
||||
} from '@blocksuite/store';
|
||||
import { createContext, provide } from '@lit/context';
|
||||
import { css, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
@@ -16,7 +20,6 @@ import type { UIEventDispatcher } from '../../event/index.js';
|
||||
import { WidgetViewMapIdentifier } from '../../identifier.js';
|
||||
import type { RangeManager } from '../../range/index.js';
|
||||
import type { BlockStdScope } from '../../scope/block-std-scope.js';
|
||||
import type { SelectionManager } from '../../selection/index.js';
|
||||
import { PropTypes, requiredProperties } from '../decorators/index.js';
|
||||
import type { ViewStore } from '../view-store.js';
|
||||
import { BLOCK_ID_ATTR, WIDGET_ID_ATTR } from './consts.js';
|
||||
@@ -114,7 +117,7 @@ export class EditorHost extends SignalWatcher(
|
||||
return this.std.range;
|
||||
}
|
||||
|
||||
get selection(): SelectionManager {
|
||||
get selection(): StoreSelectionExtension {
|
||||
return this.std.selection;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user