refactor(editor): implement uni-component in AFFiNE (#10747)

This commit is contained in:
zzj3720
2025-03-10 14:23:24 +00:00
parent 027d3a51dc
commit 4a45cc9ba4
23 changed files with 177 additions and 63 deletions

View File

@@ -5,7 +5,7 @@ import type {
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env'; import { IS_MOBILE } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { css, unsafeCSS } from 'lit'; import { css, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -63,7 +63,7 @@ export class DataViewRenderer extends SignalWatcher(
} }
`; `;
private readonly _view = createRef<{ private readonly _view = signal<{
expose: DataViewInstance; expose: DataViewInstance;
}>(); }>();

View File

@@ -3,6 +3,7 @@ import {
popMenu, popMenu,
popupTargetFromElement, popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu'; } from '@blocksuite/affine-components/context-menu';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { import {
@@ -19,10 +20,7 @@ import { repeat } from 'lit/directives/repeat.js';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from '../common/css-variable.js'; import { dataViewCommonStyle } from '../common/css-variable.js';
import { import { renderUniLit } from '../utils/uni-component/uni-component.js';
renderUniLit,
type UniComponent,
} from '../utils/uni-component/uni-component.js';
import type { SingleView } from '../view-manager/single-view.js'; import type { SingleView } from '../view-manager/single-view.js';
import { DetailSelection } from './selection.js'; import { DetailSelection } from './selection.js';

View File

@@ -11,11 +11,10 @@ import {
MoveLeftIcon, MoveLeftIcon,
MoveRightIcon, MoveRightIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core'; import { computed, signal } from '@preact/signals-core';
import { css } from 'lit'; import { css } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
import { inputConfig, typeConfig } from '../common/property-menu.js'; import { inputConfig, typeConfig } from '../common/property-menu.js';
@@ -109,7 +108,7 @@ export class RecordField extends SignalWatcher(
} }
`; `;
private readonly _cell = createRef<DataViewCellLifeCycle>(); private readonly _cell = signal<DataViewCellLifeCycle>();
_click = (e: MouseEvent) => { _click = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -1,5 +1,6 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance } from '../logical/type.js'; import type { TypeInstance } from '../logical/type.js';
import type { UniComponent } from '../utils/index.js';
export type VariableRef = { export type VariableRef = {
type: 'ref'; type: 'ref';

View File

@@ -1,6 +1,7 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance } from '../logical/type.js'; import type { TypeInstance } from '../logical/type.js';
import type { DVJSON } from '../property/types.js'; import type { DVJSON } from '../property/types.js';
import type { UniComponent } from '../utils/index.js';
export interface GroupRenderProps< export interface GroupRenderProps<
Data extends NonNullable<unknown> = NonNullable<unknown>, Data extends NonNullable<unknown> = NonNullable<unknown>,

View File

@@ -13,6 +13,10 @@ export abstract class BaseCellRenderer<
extends SignalWatcher(WithDisposable(ShadowlessElement)) extends SignalWatcher(WithDisposable(ShadowlessElement))
implements DataViewCellLifeCycle, CellRenderProps<Data, Value> implements DataViewCellLifeCycle, CellRenderProps<Data, Value>
{ {
get expose() {
return this;
}
@property({ attribute: false }) @property({ attribute: false })
accessor cell!: Cell<Value, Data>; accessor cell!: Cell<Value, Data>;

View File

@@ -1,4 +1,5 @@
import type { UniComponent } from '../utils/uni-component/index.js'; import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { Cell } from '../view-manager/cell.js'; import type { Cell } from '../view-manager/cell.js';
export interface CellRenderProps< export interface CellRenderProps<

View File

@@ -1,7 +1,6 @@
import { import type { UniComponent } from '@blocksuite/affine-shared/types';
createUniComponentFromWebComponent,
type UniComponent, import { createUniComponentFromWebComponent } from '../utils/uni-component/index.js';
} from '../utils/uni-component/index.js';
import type { BaseCellRenderer } from './base-cell.js'; import type { BaseCellRenderer } from './base-cell.js';
import type { CellRenderer, DataViewCellComponent } from './manager.js'; import type { CellRenderer, DataViewCellComponent } from './manager.js';

View File

@@ -1,17 +1,16 @@
import type { UniComponent } from './uni-component.js'; import type { UniComponent } from '@blocksuite/affine-shared/types';
export const uniMap = <T, R, P extends NonNullable<unknown>>( export const uniMap = <T, R, P extends NonNullable<unknown>>(
component: UniComponent<T, P>, component: UniComponent<T, P>,
map: (r: R) => T map: (r: R) => T
): UniComponent<R, P> => { ): UniComponent<R, P> => {
return (ele, props) => { return (ele, props, expose) => {
const result = component(ele, map(props)); const result = component(ele, map(props), expose);
return { return {
unmount: result.unmount, unmount: result.unmount,
update: props => { update: props => {
result.update(map(props)); result.update(map(props));
}, },
expose: result.expose,
}; };
}; };
}; };

View File

@@ -1,28 +1,20 @@
import type {
UniComponent,
UniComponentReturn,
} from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/lit'; import { SignalWatcher } from '@blocksuite/global/lit';
import type { Signal } from '@preact/signals-core';
import type { LitElement, PropertyValues, TemplateResult } from 'lit'; import type { LitElement, PropertyValues, TemplateResult } from 'lit';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { Ref } from 'lit/directives/ref.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
export type UniComponentReturn<
Props = NonNullable<unknown>,
Expose extends NonNullable<unknown> = NonNullable<unknown>,
> = {
update: (props: Props) => void;
unmount: () => void;
expose: Expose;
};
export type UniComponent<
Props = NonNullable<unknown>,
Expose extends NonNullable<unknown> = NonNullable<unknown>,
> = (ele: HTMLElement, props: Props) => UniComponentReturn<Props, Expose>;
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>( export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
uni: UniComponent<Props, Expose> | undefined, uni: UniComponent<Props, Expose> | undefined,
props?: Props, props?: Props,
options?: { options?: {
ref?: Ref<Expose>; ref?: Signal<Expose | undefined>;
style?: Readonly<StyleInfo>; style?: Readonly<StyleInfo>;
class?: string; class?: string;
} }
@@ -45,18 +37,21 @@ export class UniLit<
} }
`; `;
uniReturn?: UniComponentReturn<Props, Expose>; uniReturn?: UniComponentReturn<Props>;
private _expose?: Expose;
get expose(): Expose | undefined { get expose(): Expose | undefined {
return this.uniReturn?.expose; return this._expose;
} }
private mount() { private mount() {
this.uniReturn = this.uni?.(this, this.props); this.uniReturn = this.uni?.(this, this.props, value => {
if (this.ref) { if (this.ref) {
// @ts-expect-error FIXME: ts error this.ref.value = value;
this.ref.value = this.uniReturn?.expose; this._expose = value;
} }
});
} }
private unmount() { private unmount() {
@@ -91,7 +86,7 @@ export class UniLit<
accessor props!: Props; accessor props!: Props;
@property({ attribute: false }) @property({ attribute: false })
accessor ref: Ref<Expose> | undefined = undefined; accessor ref: Signal<Expose | undefined> | undefined = undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor uni: UniComponent<Props, Expose> | undefined = undefined; accessor uni: UniComponent<Props, Expose> | undefined = undefined;
@@ -103,10 +98,12 @@ export const createUniComponentFromWebComponent = <
>( >(
component: typeof LitElement component: typeof LitElement
): UniComponent<T, Expose> => { ): UniComponent<T, Expose> => {
return (ele, props) => { return (ele, props, expose) => {
const ins = new component(); const ins = new component();
Object.assign(ins, props); Object.assign(ins, props);
ele.append(ins); ele.append(ins);
// @ts-expect-error ins.expose may not exist in all component instances
expose(ins.expose);
return { return {
update: props => { update: props => {
Object.assign(ins, props); Object.assign(ins, props);
@@ -115,7 +112,6 @@ export const createUniComponentFromWebComponent = <
unmount: () => { unmount: () => {
ins.remove(); ins.remove();
}, },
expose: ins as never as Expose,
}; };
}; };
}; };
@@ -140,12 +136,13 @@ export class UniAnyRender<
export const defineUniComponent = <T, Expose extends NonNullable<unknown>>( export const defineUniComponent = <T, Expose extends NonNullable<unknown>>(
renderTemplate: (props: T, expose: Expose) => TemplateResult renderTemplate: (props: T, expose: Expose) => TemplateResult
): UniComponent<T, Expose> => { ): UniComponent<T, Expose> => {
return (ele, props) => { return (ele, props, expose) => {
const ins = new UniAnyRender<T, Expose>(); const ins = new UniAnyRender<T, Expose>();
ins.props = props; ins.props = props;
ins.expose = {} as Expose; ins.expose = {} as Expose;
ins.renderTemplate = renderTemplate; ins.renderTemplate = renderTemplate;
ele.append(ins); ele.append(ins);
expose(ins.expose);
return { return {
update: props => { update: props => {
ins.props = props; ins.props = props;
@@ -154,7 +151,6 @@ export const defineUniComponent = <T, Expose extends NonNullable<unknown>>(
unmount: () => { unmount: () => {
ins.remove(); ins.remove();
}, },
expose: ins.expose,
}; };
}; };
}; };

View File

@@ -1,9 +1,9 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { computed, type ReadonlySignal } from '@preact/signals-core'; import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { TypeInstance } from '../logical/type.js'; import type { TypeInstance } from '../logical/type.js';
import type { CellRenderer } from '../property/index.js'; import type { CellRenderer } from '../property/index.js';
import type { PropertyDataUpdater } from '../types.js'; import type { PropertyDataUpdater } from '../types.js';
import type { UniComponent } from '../utils/uni-component/index.js';
import type { Cell } from './cell.js'; import type { Cell } from './cell.js';
import type { SingleView } from './single-view.js'; import type { SingleView } from './single-view.js';

View File

@@ -1,3 +1,4 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
@@ -8,7 +9,6 @@ import type { TypeInstance } from '../logical/type.js';
import type { PropertyMetaConfig } from '../property/property-config.js'; import type { PropertyMetaConfig } from '../property/property-config.js';
import type { TraitKey } from '../traits/key.js'; import type { TraitKey } from '../traits/key.js';
import type { DatabaseFlags } from '../types.js'; import type { DatabaseFlags } from '../types.js';
import type { UniComponent } from '../utils/uni-component/index.js';
import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; import type { DataViewDataType, ViewMeta } from '../view/data-view.js';
import { type Cell, CellBase } from './cell.js'; import { type Cell, CellBase } from './cell.js';
import type { Property } from './property.js'; import type { Property } from './property.js';

View File

@@ -1,4 +1,5 @@
import type { UniComponent } from '../utils/uni-component/index.js'; import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { SingleView } from '../view-manager/single-view.js'; import type { SingleView } from '../view-manager/single-view.js';
import type { ViewManager } from '../view-manager/view-manager.js'; import type { ViewManager } from '../view-manager/view-manager.js';
import type { DataViewInstance, DataViewProps } from './types.js'; import type { DataViewInstance, DataViewProps } from './types.js';

View File

@@ -1,4 +1,5 @@
import type { UniComponent } from '../utils/uni-component/index.js'; import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { DataViewInstance } from '../view/types.js'; import type { DataViewInstance } from '../view/types.js';
export type DataViewWidgetProps = { export type DataViewWidgetProps = {

View File

@@ -3,10 +3,9 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { computed, effect } from '@preact/signals-core'; import { computed, effect, signal } from '@preact/signals-core';
import { css } from 'lit'; import { css } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
import type { import type {
@@ -51,7 +50,7 @@ export class MobileKanbanCell extends SignalWatcher(
) { ) {
static override styles = styles; static override styles = styles;
private readonly _cell = createRef<DataViewCellLifeCycle>(); private readonly _cell = signal<DataViewCellLifeCycle>();
isEditing$ = computed(() => { isEditing$ = computed(() => {
const selection = this.kanban?.props.selection$.value; const selection = this.kanban?.props.selection$.value;

View File

@@ -2,9 +2,9 @@
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { signal } from '@preact/signals-core';
import { css } from 'lit'; import { css } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
import type { import type {
@@ -59,7 +59,7 @@ export class KanbanCell extends SignalWatcher(
) { ) {
static override styles = styles; static override styles = styles;
private readonly _cell = createRef<DataViewCellLifeCycle>(); private readonly _cell = signal<DataViewCellLifeCycle>();
selectCurrentCell = (editing: boolean) => { selectCurrentCell = (editing: boolean) => {
const selectionView = this.closest( const selectionView = this.closest(

View File

@@ -1,9 +1,8 @@
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { computed, effect } from '@preact/signals-core'; import { computed, effect, signal } from '@preact/signals-core';
import { css } from 'lit'; import { css } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import { import {
type CellRenderProps, type CellRenderProps,
@@ -36,7 +35,7 @@ export class MobileTableCell extends SignalWatcher(
} }
`; `;
private readonly _cell = createRef<DataViewCellLifeCycle>(); private readonly _cell = signal<DataViewCellLifeCycle>();
@property({ attribute: false }) @property({ attribute: false })
accessor column!: TableColumn; accessor column!: TableColumn;

View File

@@ -1,9 +1,8 @@
import { ShadowlessElement } from '@blocksuite/block-std'; import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { computed } from '@preact/signals-core'; import { computed, signal } from '@preact/signals-core';
import { css } from 'lit'; import { css } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import { renderUniLit } from '../../../core/index.js'; import { renderUniLit } from '../../../core/index.js';
import type { import type {
@@ -40,7 +39,7 @@ export class DatabaseCellContainer extends SignalWatcher(
} }
`; `;
private readonly _cell = createRef<DataViewCellLifeCycle>(); private readonly _cell = signal<DataViewCellLifeCycle>();
@property({ attribute: false }) @property({ attribute: false })
accessor column!: TableColumn; accessor column!: TableColumn;

View File

@@ -5,7 +5,7 @@ import type {
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import type { BlockComponent } from '@blocksuite/block-std'; import type { BlockComponent } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
export * from './uni-component';
export interface EditingState { export interface EditingState {
element: BlockComponent; element: BlockComponent;
model: BlockModel; model: BlockModel;

View File

@@ -0,0 +1,15 @@
export type ExposeWrapper<
Expose extends NonNullable<unknown> = NonNullable<unknown>,
> = (value?: Expose) => void;
export type UniComponentReturn<Props = NonNullable<unknown>> = {
update: (props: Props) => void;
unmount: () => void;
};
export type UniComponent<
Props = NonNullable<unknown>,
Expose extends NonNullable<unknown> = NonNullable<unknown>,
> = (
ele: HTMLElement,
props: Props,
expose: ExposeWrapper<Expose>
) => UniComponentReturn<Props>;

View File

@@ -3,3 +3,4 @@ export {
useLitPortal, useLitPortal,
useLitPortalFactory, useLitPortalFactory,
} from './lit-portal'; } from './lit-portal';
export { uniReactRoot } from './uni-component';

View File

@@ -0,0 +1,96 @@
import type {
ExposeWrapper,
UniComponent,
UniComponentReturn,
} from '@blocksuite/affine-shared/types';
import { nanoid } from 'nanoid';
import {
type ComponentType,
memo,
type ReactNode,
useEffect,
useState,
} from 'react';
import { createPortal } from 'react-dom';
const UniReactNode = memo(
function UniReactNode(props: {
ele: HTMLElement;
component: ComponentType<any>;
dataRef: DataRef<any>;
expose: ExposeWrapper<any>;
}) {
const Component = props.component;
const [data, setData] = useState(props.dataRef.value);
useEffect(() => {
return props.dataRef.subscribe(() => {
setData(props.dataRef.value);
});
}, [props.dataRef]);
return createPortal(<Component ref={props.expose} {...data} />, props.ele);
},
() => true
);
type DataRef<T> = {
value: T;
subscribe: (update: () => void) => void;
update: (props: T) => void;
};
const createDataRef = <T,>(data: T): DataRef<T> => {
let listener = () => {};
const ref = {
value: data,
subscribe: (update: () => void) => {
listener = update;
return () => {
listener = () => {};
};
},
update: (props: T) => {
ref.value = props;
listener();
},
};
return ref;
};
export const createUniReactRoot = () => {
const nodes: Set<ReactNode> = new Set();
let updateNodes = () => {};
return {
Root: () => {
const [, forceUpdate] = useState({});
useEffect(() => {
updateNodes = () => forceUpdate({});
}, []);
return nodes;
},
createUniComponent: <T, E extends NonNullable<unknown>>(
component: ComponentType<T>
): UniComponent<T, E> => {
return (ele: HTMLElement, props: T, expose) => {
const dataRef = createDataRef(props);
const node = (
<UniReactNode
key={nanoid()}
expose={expose}
ele={ele}
component={component}
dataRef={dataRef}
/>
);
nodes.add(node);
updateNodes();
return {
update: (props: T) => {
dataRef.update(props);
},
unmount: () => {
nodes.delete(node);
updateNodes();
},
} satisfies UniComponentReturn<T>;
};
},
};
};
export const uniReactRoot = createUniReactRoot();

View File

@@ -1,4 +1,8 @@
import { ConfirmModalProvider, PromptModalProvider } from '@affine/component'; import {
ConfirmModalProvider,
PromptModalProvider,
uniReactRoot,
} from '@affine/component';
import { ProviderComposer } from '@affine/component/provider-composer'; import { ProviderComposer } from '@affine/component/provider-composer';
import { ThemeProvider } from '@affine/core/components/theme-provider'; import { ThemeProvider } from '@affine/core/components/theme-provider';
import type { createStore } from 'jotai'; import type { createStore } from 'jotai';
@@ -25,6 +29,7 @@ export function AffineContext(props: AffineContextProps) {
)} )}
> >
{props.children} {props.children}
<uniReactRoot.Root />,
</ProviderComposer> </ProviderComposer>
); );
} }