// // Copyright 2023 DXOS.org // import type * as Context from 'effect/Context'; import type * as Effect from 'effect/Effect'; import { type MakeOptional } from '@dxos/util'; /** * Root node ID. */ export const RootId = 'root'; /** * Root node type. */ export const RootType = 'org.dxos.type.graphRoot'; /** * Action node type. */ export const ActionType = 'org.dxos.type.graphAction'; /** * Action group node type. */ export const ActionGroupType = 'org.dxos.type.graphActionGroup'; /** * Represents a node in the graph. */ // TODO(wittjosiah): Use Effect Schema. // TODO(burdon): Rename GraphNode. Node is already in the global namespace. export type Node = Record> = Readonly<{ /** * Globally unique ID. */ // TODO(burdon): Allow string array, which is concatenated. id: string; /** * Typename of the data the node represents. */ type: string; /** * Keys in of the properties which should be cached. * If defined, the node will be included in the cache. * If undefined, the node will not be included in the cache. */ cacheable?: string[]; /** * Properties of the node relevant to displaying the node. */ properties: Readonly; /** * Data the node represents. */ // TODO(burdon): Type system (e.g., minimally provide identifier string vs. TypedObject vs. Graph mixin type system)? // type field would prevent convoluted sniffing of object properties. And allow direct pass-through for ECHO TypedObjects. data: TData; }>; export type NodeFilter = Record> = ( node: Node>, connectedNode: Node, ) => node is Node; export type RelationDirection = 'outbound' | 'inbound'; export type Relation = Readonly<{ kind: string; direction: RelationDirection; }>; export type RelationInput = Relation | string; export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction }); // TODO(wittjosiah): Consider moving these helpers out of the core API. export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction); export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction); export const isGraphNode = (data: unknown): data is Node => data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties ? typeof data.properties === 'object' && 'data' in data : false; export type NodeArg = Record> = MakeOptional< Node, 'data' | 'properties' | 'cacheable' > & { /** Will automatically add nodes with an edge from this node to each. */ nodes?: NodeArg[]; /** Will automatically add actions with an edge from this node to each. An action child may itself * be an action group (e.g. a toolbar dropdown group), so groups are accepted alongside actions. */ actions?: NodeArg | typeof actionGroupSymbol>[]; /** Will automatically add specified edges. */ edges?: [string, RelationInput][]; }; // // Actions // export type InvokeProps = { /** Node the invoked action is connected to. */ parent?: Node; /** Path from root to the node in the current tree context. */ path?: string[]; caller?: string; /** Input modifiers held during the gesture that triggered the action (e.g. shift-clicking a menu item). */ modifiers?: { shift?: boolean }; }; /** * Action data is an Effect-returning function. * The Effect is provided with captured context at execution time. */ export type ActionData = (params?: InvokeProps) => Effect.Effect; /** * Context captured at extension creation time. * Automatically provided to action Effects at execution. */ export type ActionContext = Context.Context; export type Action = Record> = Readonly< Omit, 'properties'> & { properties: Readonly; /** Captured context from extension creation. Provided automatically at action execution. */ _actionContext?: ActionContext; } >; export const isAction = (data: unknown): data is Action => isGraphNode(data) ? typeof data.data === 'function' && data.type === ActionType : false; export const actionGroupSymbol = Symbol('ActionGroup'); export type ActionGroup = Record> = Readonly< Omit, 'properties'> & { properties: Readonly; } >; export const isActionGroup = (data: unknown): data is ActionGroup => isGraphNode(data) ? data.data === actionGroupSymbol && data.type === ActionGroupType : false; export type ActionLike = Action | ActionGroup; export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data); /** * Tests whether a node's `disposition` property (a single string or an array, letting one node opt * into multiple surfaces at once) includes any of `key`. Every surface that routes nodes/actions by * disposition (toolbar, nav-tree list-item, sigil menu, …) should filter through this rather than * comparing `properties.disposition` directly, so a node can multi-target surfaces. */ export const hasDisposition = (node: Pick, key: string | string[]): boolean => { const disposition = node.properties.disposition; const dispositions = Array.isArray(disposition) ? disposition : disposition !== undefined ? [disposition] : []; const keys = Array.isArray(key) ? key : [key]; return dispositions.some((candidate) => keys.includes(candidate)); }; // // Node Factories // /** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */ export const make = = Record>( arg: NodeArg, ): NodeArg => arg; /** Create an action node. Automatically sets `type: ActionType`. */ export const makeAction = ( arg: Omit>, 'type' | 'nodes' | 'edges'>, ): NodeArg> => ({ ...arg, type: ActionType, }); /** Create an action group node. Automatically sets `type` and `data`. */ export const makeActionGroup = ( arg: Omit, 'type' | 'data' | 'nodes' | 'edges'>, ): NodeArg => ({ ...arg, type: ActionGroupType, data: actionGroupSymbol, });