@ai-composer/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -0
- package/dist/commands/builtins.d.ts +41 -0
- package/dist/commands/builtins.d.ts.map +1 -0
- package/dist/commands/builtins.js +116 -0
- package/dist/commands/builtins.js.map +1 -0
- package/dist/commands/registry.d.ts +27 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +29 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/editor.d.ts +185 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +558 -0
- package/dist/editor.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/events/event-bus.d.ts +19 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +51 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/events.d.ts +94 -0
- package/dist/events/events.d.ts.map +1 -0
- package/dist/events/events.js +6 -0
- package/dist/events/events.js.map +1 -0
- package/dist/factory.d.ts +17 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +18 -0
- package/dist/factory.js.map +1 -0
- package/dist/history/history.d.ts +48 -0
- package/dist/history/history.d.ts.map +1 -0
- package/dist/history/history.js +71 -0
- package/dist/history/history.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/model/document.d.ts +54 -0
- package/dist/model/document.d.ts.map +1 -0
- package/dist/model/document.js +177 -0
- package/dist/model/document.js.map +1 -0
- package/dist/model/nodes.d.ts +80 -0
- package/dist/model/nodes.d.ts.map +1 -0
- package/dist/model/nodes.js +64 -0
- package/dist/model/nodes.js.map +1 -0
- package/dist/model/selection.d.ts +40 -0
- package/dist/model/selection.d.ts.map +1 -0
- package/dist/model/selection.js +79 -0
- package/dist/model/selection.js.map +1 -0
- package/dist/nodes/registry.d.ts +39 -0
- package/dist/nodes/registry.d.ts.map +1 -0
- package/dist/nodes/registry.js +93 -0
- package/dist/nodes/registry.js.map +1 -0
- package/dist/package.json +21 -0
- package/dist/plugins/registry.d.ts +53 -0
- package/dist/plugins/registry.d.ts.map +1 -0
- package/dist/plugins/registry.js +102 -0
- package/dist/plugins/registry.js.map +1 -0
- package/dist/serialization/builtins.d.ts +30 -0
- package/dist/serialization/builtins.d.ts.map +1 -0
- package/dist/serialization/builtins.js +102 -0
- package/dist/serialization/builtins.js.map +1 -0
- package/dist/serialization/index.d.ts +4 -0
- package/dist/serialization/index.d.ts.map +1 -0
- package/dist/serialization/index.js +3 -0
- package/dist/serialization/index.js.map +1 -0
- package/dist/serialization/registry.d.ts +17 -0
- package/dist/serialization/registry.d.ts.map +1 -0
- package/dist/serialization/registry.js +35 -0
- package/dist/serialization/registry.js.map +1 -0
- package/dist/serialization/serializers.d.ts +12 -0
- package/dist/serialization/serializers.d.ts.map +1 -0
- package/dist/serialization/serializers.js +3 -0
- package/dist/serialization/serializers.js.map +1 -0
- package/dist/state/state.d.ts +54 -0
- package/dist/state/state.d.ts.map +1 -0
- package/dist/state/state.js +6 -0
- package/dist/state/state.js.map +1 -0
- package/dist/triggers/engine.d.ts +102 -0
- package/dist/triggers/engine.d.ts.map +1 -0
- package/dist/triggers/engine.js +235 -0
- package/dist/triggers/engine.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/html.d.ts +3 -0
- package/dist/utils/html.d.ts.map +1 -0
- package/dist/utils/html.js +10 -0
- package/dist/utils/html.js.map +1 -0
- package/package.json +20 -0
- package/project.json +56 -0
- package/src/commands/builtins.ts +142 -0
- package/src/commands/registry.ts +53 -0
- package/src/commands.test.ts +118 -0
- package/src/editor.test.ts +311 -0
- package/src/editor.ts +826 -0
- package/src/errors.ts +25 -0
- package/src/events/event-bus.test.ts +62 -0
- package/src/events/event-bus.ts +72 -0
- package/src/events/events.ts +120 -0
- package/src/factory.ts +21 -0
- package/src/history/history.ts +108 -0
- package/src/index.ts +201 -0
- package/src/model/document.test.ts +130 -0
- package/src/model/document.ts +207 -0
- package/src/model/nodes.ts +151 -0
- package/src/model/selection.ts +107 -0
- package/src/nodes/registry.ts +142 -0
- package/src/plugins/registry.ts +163 -0
- package/src/serialization/builtins.ts +123 -0
- package/src/serialization/index.ts +20 -0
- package/src/serialization/registry.ts +49 -0
- package/src/serialization/serializers.ts +15 -0
- package/src/state/state.ts +57 -0
- package/src/triggers/engine.ts +317 -0
- package/src/triggers.test.ts +138 -0
- package/src/types.ts +42 -0
- package/src/utils/html.ts +9 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.spec.json +16 -0
- package/vitest.config.ts +11 -0
package/src/editor.ts
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor engine (ADR-0001). `AIComposer` is the public, framework-neutral
|
|
3
|
+
* contract implemented by `AIComposerImpl` and created via
|
|
4
|
+
* `createAIComposer()`. It owns the document, selection, state, history,
|
|
5
|
+
* registries and the trigger engine — but no DOM.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEventBus, type Unsubscribe } from './events/event-bus';
|
|
9
|
+
import type {
|
|
10
|
+
BeforeSubmitEvent,
|
|
11
|
+
ChangeSource,
|
|
12
|
+
AIComposerBus,
|
|
13
|
+
AIComposerEventMap,
|
|
14
|
+
AIComposerEventType,
|
|
15
|
+
TriggerCloseReason,
|
|
16
|
+
} from './events/events';
|
|
17
|
+
import {
|
|
18
|
+
coerceDocument,
|
|
19
|
+
deleteDocumentRange,
|
|
20
|
+
documentsEqual,
|
|
21
|
+
findNodeIndex,
|
|
22
|
+
getNodesOfType,
|
|
23
|
+
insertNodesAt,
|
|
24
|
+
isEmptyDocument,
|
|
25
|
+
normalizeNodes,
|
|
26
|
+
sanitizeDocument,
|
|
27
|
+
type AIComposerDocument,
|
|
28
|
+
} from './model/document';
|
|
29
|
+
import {
|
|
30
|
+
createTextNode,
|
|
31
|
+
ensureNodeKey,
|
|
32
|
+
type AttachmentNode,
|
|
33
|
+
type AIComposerNode,
|
|
34
|
+
} from './model/nodes';
|
|
35
|
+
import {
|
|
36
|
+
clampSelection,
|
|
37
|
+
createPosition,
|
|
38
|
+
createSelection,
|
|
39
|
+
equalsSelection,
|
|
40
|
+
fromGlobalOffset,
|
|
41
|
+
getRange,
|
|
42
|
+
toGlobalOffset,
|
|
43
|
+
type DocumentRange,
|
|
44
|
+
type Position,
|
|
45
|
+
type SelectionState,
|
|
46
|
+
} from './model/selection';
|
|
47
|
+
import type { AIComposerState, SuggestionItem, TriggerState } from './state/state';
|
|
48
|
+
import { createCommandRegistry, type CommandRegistry, type AIComposerCommand } from './commands/registry';
|
|
49
|
+
import { createBuiltinCommands } from './commands/builtins';
|
|
50
|
+
import { createDefaultNodeRegistry, type NodeDefinition, type NodeRegistry } from './nodes/registry';
|
|
51
|
+
import {
|
|
52
|
+
TriggerEngine,
|
|
53
|
+
createTriggerRegistry,
|
|
54
|
+
type AIComposerTrigger,
|
|
55
|
+
type TriggerHost,
|
|
56
|
+
type TriggerRegistry,
|
|
57
|
+
} from './triggers/engine';
|
|
58
|
+
import { EditorHistory } from './history/history';
|
|
59
|
+
import {
|
|
60
|
+
createPluginRegistry,
|
|
61
|
+
type PluginContext,
|
|
62
|
+
type PluginRegistry,
|
|
63
|
+
type AIComposerPlugin,
|
|
64
|
+
} from './plugins/registry';
|
|
65
|
+
import {
|
|
66
|
+
createDefaultSerializers,
|
|
67
|
+
createSerializerRegistry,
|
|
68
|
+
type SerializationFormat,
|
|
69
|
+
type Serializer,
|
|
70
|
+
type SerializerRegistry,
|
|
71
|
+
} from './serialization/index';
|
|
72
|
+
import { ErrorCode, AIComposerError } from './errors';
|
|
73
|
+
import type { AIComposerConfig, AIComposerOptions } from './types';
|
|
74
|
+
|
|
75
|
+
/** Optional DOM/view handle attached by the DOM layer or an adapter. */
|
|
76
|
+
export interface EditorView {
|
|
77
|
+
focus(): void;
|
|
78
|
+
blur(): void;
|
|
79
|
+
/** Re-render the document into the view. */
|
|
80
|
+
render?(): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SetValueOptions {
|
|
84
|
+
source?: ChangeSource;
|
|
85
|
+
/** Record as an undo step (default true). */
|
|
86
|
+
history?: boolean;
|
|
87
|
+
label?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TransactionOptions {
|
|
91
|
+
label?: string;
|
|
92
|
+
source?: ChangeSource;
|
|
93
|
+
history?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InsertNodeOptions {
|
|
97
|
+
at?: Position;
|
|
98
|
+
/** Insert a trailing space and place the caret after it (default true). */
|
|
99
|
+
trailingSpace?: boolean;
|
|
100
|
+
source?: ChangeSource;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface MutationOptions {
|
|
104
|
+
source: ChangeSource;
|
|
105
|
+
label: string;
|
|
106
|
+
history: boolean;
|
|
107
|
+
merge: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type StateListener = (state: AIComposerState) => void;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Public editor contract. Stable across framework adapters — if a method makes
|
|
114
|
+
* sense without a browser, it belongs here, not in an adapter.
|
|
115
|
+
*/
|
|
116
|
+
export interface AIComposer {
|
|
117
|
+
// -- value ---------------------------------------------------------------
|
|
118
|
+
getValue(): AIComposerDocument;
|
|
119
|
+
setValue(value: AIComposerDocument | AIComposerNode[] | string, options?: SetValueOptions): void;
|
|
120
|
+
clear(): void;
|
|
121
|
+
/** Replace a range with nodes in a single history step. */
|
|
122
|
+
replaceRange(range: DocumentRange, nodes: AIComposerNode[], options?: { trailingSpace?: boolean; source?: ChangeSource }): void;
|
|
123
|
+
|
|
124
|
+
// -- nodes ---------------------------------------------------------------
|
|
125
|
+
insertText(text: string, at?: Position): void;
|
|
126
|
+
insertNode(node: AIComposerNode, options?: InsertNodeOptions): void;
|
|
127
|
+
removeNode(key: string): void;
|
|
128
|
+
/** Reconcile a document parsed from the view (user typing). View-layer API. */
|
|
129
|
+
applyViewUpdate(next: AIComposerDocument, selection?: SelectionState): void;
|
|
130
|
+
|
|
131
|
+
// -- selection -----------------------------------------------------------
|
|
132
|
+
getSelection(): SelectionState;
|
|
133
|
+
setSelection(selection: SelectionState): void;
|
|
134
|
+
|
|
135
|
+
// -- focus / lifecycle ----------------------------------------------------
|
|
136
|
+
focus(): void;
|
|
137
|
+
blur(): void;
|
|
138
|
+
/** Called by the attached view on native focus changes. View-layer API. */
|
|
139
|
+
setFocused(focused: boolean): void;
|
|
140
|
+
attachView(view: EditorView): Unsubscribe;
|
|
141
|
+
/** True once destroy() has run — adapters use this to rebuild safely. */
|
|
142
|
+
isDestroyed(): boolean;
|
|
143
|
+
destroy(): void;
|
|
144
|
+
|
|
145
|
+
// -- submit ----------------------------------------------------------------
|
|
146
|
+
submit(): Promise<void>;
|
|
147
|
+
|
|
148
|
+
// -- history ----------------------------------------------------------------
|
|
149
|
+
undo(): void;
|
|
150
|
+
redo(): void;
|
|
151
|
+
/** Group mutations into one undo step. */
|
|
152
|
+
transaction(fn: () => void, options?: TransactionOptions): void;
|
|
153
|
+
|
|
154
|
+
// -- commands ----------------------------------------------------------------
|
|
155
|
+
readonly commands: CommandRegistry;
|
|
156
|
+
executeCommand(id: string, payload?: unknown): Promise<void>;
|
|
157
|
+
canExecuteCommand(id: string, payload?: unknown): boolean;
|
|
158
|
+
|
|
159
|
+
// -- triggers ------------------------------------------------------------
|
|
160
|
+
readonly triggers: TriggerRegistry;
|
|
161
|
+
getActiveTrigger(): TriggerState | null;
|
|
162
|
+
acceptSuggestion(item?: SuggestionItem): Promise<void>;
|
|
163
|
+
moveSuggestionSelection(delta: number): void;
|
|
164
|
+
closeTrigger(reason?: TriggerCloseReason): void;
|
|
165
|
+
/** Insert a trigger character at the caret, opening its suggestion session. */
|
|
166
|
+
openTrigger(triggerId: string): void;
|
|
167
|
+
|
|
168
|
+
// -- registries (plugins) --------------------------------------------------
|
|
169
|
+
readonly nodes: NodeRegistry;
|
|
170
|
+
readonly serializers: SerializerRegistry;
|
|
171
|
+
readonly pluginRegistry: PluginRegistry;
|
|
172
|
+
registerPlugin(plugin: AIComposerPlugin): Unsubscribe;
|
|
173
|
+
registerTrigger(trigger: AIComposerTrigger): Unsubscribe;
|
|
174
|
+
registerCommand(command: AIComposerCommand): Unsubscribe;
|
|
175
|
+
registerNodeType(definition: NodeDefinition): Unsubscribe;
|
|
176
|
+
registerSerializer(serializer: Serializer): Unsubscribe;
|
|
177
|
+
|
|
178
|
+
// -- events --------------------------------------------------------------
|
|
179
|
+
on<K extends AIComposerEventType>(type: K, handler: (event: AIComposerEventMap[K]) => void): Unsubscribe;
|
|
180
|
+
once<K extends AIComposerEventType>(type: K, handler: (event: AIComposerEventMap[K]) => void): Unsubscribe;
|
|
181
|
+
off<K extends AIComposerEventType>(type: K, handler: (event: AIComposerEventMap[K]) => void): void;
|
|
182
|
+
|
|
183
|
+
// -- state ---------------------------------------------------------------
|
|
184
|
+
getState(): AIComposerState;
|
|
185
|
+
subscribe(listener: StateListener): Unsubscribe;
|
|
186
|
+
|
|
187
|
+
// -- config --------------------------------------------------------------
|
|
188
|
+
getConfig(): AIComposerConfig;
|
|
189
|
+
configure(patch: Partial<AIComposerOptions>): void;
|
|
190
|
+
setMode(mode: string): void;
|
|
191
|
+
setDisabled(disabled: boolean): void;
|
|
192
|
+
setReadonly(readonly: boolean): void;
|
|
193
|
+
|
|
194
|
+
// -- serialization ---------------------------------------------------------
|
|
195
|
+
serialize(format: SerializationFormat): unknown;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class AIComposerImpl implements AIComposer {
|
|
199
|
+
readonly events: AIComposerBus = createEventBus<AIComposerEventMap>();
|
|
200
|
+
readonly commands: CommandRegistry = createCommandRegistry();
|
|
201
|
+
readonly triggers: TriggerRegistry = createTriggerRegistry();
|
|
202
|
+
readonly nodes: NodeRegistry = createDefaultNodeRegistry();
|
|
203
|
+
readonly serializers: SerializerRegistry = createSerializerRegistry();
|
|
204
|
+
readonly pluginRegistry: PluginRegistry = createPluginRegistry();
|
|
205
|
+
|
|
206
|
+
private readonly triggerEngine: TriggerEngine;
|
|
207
|
+
private readonly history: EditorHistory;
|
|
208
|
+
|
|
209
|
+
private config: AIComposerConfig;
|
|
210
|
+
private document: AIComposerDocument;
|
|
211
|
+
private selection: SelectionState = createSelection();
|
|
212
|
+
private focused = false;
|
|
213
|
+
private submitting = false;
|
|
214
|
+
private destroyed = false;
|
|
215
|
+
private mutationDepth = 0;
|
|
216
|
+
private view: EditorView | null = null;
|
|
217
|
+
private stateListeners = new Set<StateListener>();
|
|
218
|
+
private stateCache: AIComposerState | null = null;
|
|
219
|
+
|
|
220
|
+
constructor(options: AIComposerOptions = {}) {
|
|
221
|
+
this.config = {
|
|
222
|
+
mode: options.mode ?? 'default',
|
|
223
|
+
placeholder: options.placeholder ?? '',
|
|
224
|
+
submitKey: options.submitKey ?? 'enter',
|
|
225
|
+
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
|
226
|
+
...(options.readonly !== undefined ? { readonly: options.readonly } : {}),
|
|
227
|
+
...(options.submit ? { submit: options.submit } : {}),
|
|
228
|
+
...(options.history ? { history: options.history } : {}),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
this.document = coerceDocument(options.value);
|
|
232
|
+
this.selection = createSelection(
|
|
233
|
+
fromGlobalOffset(this.document, documentLengthOf(this.document)),
|
|
234
|
+
);
|
|
235
|
+
this.history = new EditorHistory(this.config.history ?? {});
|
|
236
|
+
this.triggerEngine = new TriggerEngine(this.createTriggerHost());
|
|
237
|
+
|
|
238
|
+
for (const serializer of [...createDefaultSerializers(), ...(options.serializers ?? [])]) {
|
|
239
|
+
this.serializers.register(serializer);
|
|
240
|
+
}
|
|
241
|
+
for (const definition of options.nodeTypes ?? []) {
|
|
242
|
+
this.nodes.register(definition);
|
|
243
|
+
}
|
|
244
|
+
for (const command of createBuiltinCommands(this)) {
|
|
245
|
+
this.commands.register(command);
|
|
246
|
+
}
|
|
247
|
+
for (const plugin of options.plugins ?? []) {
|
|
248
|
+
this.pluginRegistry.install(plugin, this.createPluginContext());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.stateCache = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -- value ---------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
getValue(): AIComposerDocument {
|
|
257
|
+
return this.document;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setValue(value: AIComposerDocument | AIComposerNode[] | string, options: SetValueOptions = {}): void {
|
|
261
|
+
this.assertAlive();
|
|
262
|
+
const next = coerceDocument(value);
|
|
263
|
+
this.mutate(
|
|
264
|
+
() => {
|
|
265
|
+
this.document = next;
|
|
266
|
+
this.selection = createSelection(fromGlobalOffset(next, documentLengthOf(next)));
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
source: options.source ?? 'api',
|
|
270
|
+
label: options.label ?? 'setValue',
|
|
271
|
+
history: options.history !== false,
|
|
272
|
+
merge: false,
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
clear(): void {
|
|
278
|
+
this.setValue([], { label: 'clear' });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
replaceRange(
|
|
282
|
+
range: DocumentRange,
|
|
283
|
+
nodes: AIComposerNode[],
|
|
284
|
+
options: { trailingSpace?: boolean; source?: ChangeSource } = {},
|
|
285
|
+
): void {
|
|
286
|
+
this.assertAlive();
|
|
287
|
+
this.mutate(
|
|
288
|
+
() => {
|
|
289
|
+
const { document, caret } = deleteDocumentRange(this.document, range);
|
|
290
|
+
const inserted = options.trailingSpace ? [...nodes, createTextNode(' ')] : [...nodes];
|
|
291
|
+
const result = insertNodesAt(document, caret, inserted);
|
|
292
|
+
this.document = result.document;
|
|
293
|
+
this.selection = createSelection(result.caret);
|
|
294
|
+
},
|
|
295
|
+
{ source: options.source ?? 'api', label: 'replaceRange', history: true, merge: false },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -- nodes ---------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
insertText(text: string, at?: Position): void {
|
|
302
|
+
if (!text) return;
|
|
303
|
+
this.assertAlive();
|
|
304
|
+
this.mutate(
|
|
305
|
+
() => {
|
|
306
|
+
if (at) this.selection = createSelection(at);
|
|
307
|
+
this.deleteSelectionIfNeeded();
|
|
308
|
+
const result = insertNodesAt(this.document, this.selection.focus, [createTextNode(text)]);
|
|
309
|
+
this.document = result.document;
|
|
310
|
+
this.selection = createSelection(result.caret);
|
|
311
|
+
},
|
|
312
|
+
{ source: 'api', label: 'insertText', history: true, merge: false },
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
insertNode(node: AIComposerNode, options: InsertNodeOptions = {}): void {
|
|
317
|
+
this.assertAlive();
|
|
318
|
+
const prepared = ensureNodeKey(node);
|
|
319
|
+
this.mutate(
|
|
320
|
+
() => {
|
|
321
|
+
if (options.at) this.selection = createSelection(options.at);
|
|
322
|
+
this.deleteSelectionIfNeeded();
|
|
323
|
+
const inserted =
|
|
324
|
+
options.trailingSpace === false ? [prepared] : [prepared, createTextNode(' ')];
|
|
325
|
+
const result = insertNodesAt(this.document, this.selection.focus, inserted);
|
|
326
|
+
this.document = result.document;
|
|
327
|
+
this.selection = createSelection(result.caret);
|
|
328
|
+
},
|
|
329
|
+
{ source: options.source ?? 'api', label: 'insertNode', history: true, merge: false },
|
|
330
|
+
);
|
|
331
|
+
const index = findNodeIndex(this.document, prepared.key);
|
|
332
|
+
if (index >= 0) {
|
|
333
|
+
this.events.emit('nodeInsert', { node: prepared, index });
|
|
334
|
+
if (prepared.type === 'attachment') {
|
|
335
|
+
this.events.emit('attachmentAdd', { attachment: prepared as AttachmentNode });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
removeNode(key: string): void {
|
|
341
|
+
this.assertAlive();
|
|
342
|
+
const index = findNodeIndex(this.document, key);
|
|
343
|
+
if (index < 0) return;
|
|
344
|
+
const node = this.document.nodes[index];
|
|
345
|
+
const startOffset = toGlobalOffset(this.document, createPosition(index, 0));
|
|
346
|
+
this.mutate(
|
|
347
|
+
() => {
|
|
348
|
+
this.document = {
|
|
349
|
+
nodes: this.document.nodes.filter((candidate) => candidate.key !== key),
|
|
350
|
+
...(this.document.metadata ? { metadata: this.document.metadata } : {}),
|
|
351
|
+
};
|
|
352
|
+
this.selection = createSelection(fromGlobalOffset(this.document, startOffset));
|
|
353
|
+
},
|
|
354
|
+
{ source: 'api', label: 'removeNode', history: true, merge: false },
|
|
355
|
+
);
|
|
356
|
+
this.events.emit('nodeRemove', { node, index });
|
|
357
|
+
if (node.type === 'attachment') {
|
|
358
|
+
this.events.emit('attachmentRemove', { attachment: node as AttachmentNode });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
applyViewUpdate(next: AIComposerDocument, selection?: SelectionState): void {
|
|
363
|
+
this.assertAlive();
|
|
364
|
+
if (documentsEqual(this.document, next)) {
|
|
365
|
+
if (selection && !equalsSelection(clampSelection(next, selection), this.selection)) {
|
|
366
|
+
this.setSelection(selection);
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const sanitized = sanitizeDocument(next, (type) => this.nodes.has(type));
|
|
371
|
+
this.mutate(
|
|
372
|
+
() => {
|
|
373
|
+
this.document = sanitized;
|
|
374
|
+
this.selection = clampSelection(sanitized, selection ?? this.selection);
|
|
375
|
+
},
|
|
376
|
+
// Typing merges within the history window so undo steps feel natural.
|
|
377
|
+
{ source: 'user', label: 'input', history: true, merge: true },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// -- selection -----------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
getSelection(): SelectionState {
|
|
384
|
+
return this.selection;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
setSelection(selection: SelectionState): void {
|
|
388
|
+
this.assertAlive();
|
|
389
|
+
const next = clampSelection(this.document, selection);
|
|
390
|
+
if (equalsSelection(next, this.selection)) return;
|
|
391
|
+
this.selection = next;
|
|
392
|
+
this.invalidateState();
|
|
393
|
+
this.notify();
|
|
394
|
+
this.events.emit('selectionChange', { selection: next });
|
|
395
|
+
this.triggerEngine.handleSelectionChange();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// -- focus / lifecycle ------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
focus(): void {
|
|
401
|
+
this.assertAlive();
|
|
402
|
+
if (this.view) {
|
|
403
|
+
this.view.focus();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.setFocused(true);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
blur(): void {
|
|
410
|
+
this.assertAlive();
|
|
411
|
+
if (this.view) {
|
|
412
|
+
this.view.blur();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
this.setFocused(false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
setFocused(focused: boolean): void {
|
|
419
|
+
if (this.destroyed || this.focused === focused) return;
|
|
420
|
+
this.focused = focused;
|
|
421
|
+
this.invalidateState();
|
|
422
|
+
this.notify();
|
|
423
|
+
this.events.emit(focused ? 'focus' : 'blur', { focused });
|
|
424
|
+
if (!focused) this.triggerEngine.close('blur');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
attachView(view: EditorView): Unsubscribe {
|
|
428
|
+
this.view = view;
|
|
429
|
+
return () => {
|
|
430
|
+
if (this.view === view) this.view = null;
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
destroy(): void {
|
|
435
|
+
if (this.destroyed) return;
|
|
436
|
+
this.destroyed = true;
|
|
437
|
+
this.triggerEngine.close('editor-destroy');
|
|
438
|
+
this.events.emit('destroy', { target: 'editor' });
|
|
439
|
+
this.pluginRegistry.destroyAll();
|
|
440
|
+
this.view = null;
|
|
441
|
+
this.events.clear();
|
|
442
|
+
this.commands.clear();
|
|
443
|
+
this.triggers.clear();
|
|
444
|
+
this.serializers.clear();
|
|
445
|
+
this.stateListeners.clear();
|
|
446
|
+
this.history.reset();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
isDestroyed(): boolean {
|
|
450
|
+
return this.destroyed;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// -- submit ----------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
async submit(): Promise<void> {
|
|
456
|
+
this.assertAlive();
|
|
457
|
+
const state = this.getState();
|
|
458
|
+
if (state.disabled || state.readonly || this.submitting) return;
|
|
459
|
+
const allowEmpty = this.config.submit?.allowEmpty ?? false;
|
|
460
|
+
if (!allowEmpty && isEmptyDocument(this.document) && state.attachments.length === 0) return;
|
|
461
|
+
|
|
462
|
+
const before: BeforeSubmitEvent = {
|
|
463
|
+
value: this.document,
|
|
464
|
+
defaultPrevented: false,
|
|
465
|
+
preventDefault() {
|
|
466
|
+
this.defaultPrevented = true;
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
this.events.emit('beforeSubmit', before);
|
|
470
|
+
if (before.defaultPrevented) return;
|
|
471
|
+
|
|
472
|
+
this.submitting = true;
|
|
473
|
+
this.invalidateState();
|
|
474
|
+
this.notify();
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await this.config.submit?.onSubmit?.(this.document, this);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
this.events.emit('error', { error: error instanceof Error ? error : new Error(String(error)), phase: 'submit' });
|
|
480
|
+
} finally {
|
|
481
|
+
this.submitting = false;
|
|
482
|
+
this.invalidateState();
|
|
483
|
+
this.notify();
|
|
484
|
+
this.events.emit('submit', { value: this.document });
|
|
485
|
+
if (this.config.submit?.clearOnSubmit) this.clear();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// -- history ----------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
undo(): void {
|
|
492
|
+
this.assertAlive();
|
|
493
|
+
const previous = this.history.undo({ document: this.document, selection: this.selection });
|
|
494
|
+
if (!previous) return;
|
|
495
|
+
this.restore(previous, 'undo');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
redo(): void {
|
|
499
|
+
this.assertAlive();
|
|
500
|
+
const next = this.history.redo({ document: this.document, selection: this.selection });
|
|
501
|
+
if (!next) return;
|
|
502
|
+
this.restore(next, 'redo');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
transaction(fn: () => void, options: TransactionOptions = {}): void {
|
|
506
|
+
this.assertAlive();
|
|
507
|
+
// Nested transactions join the outer one.
|
|
508
|
+
if (this.mutationDepth > 0) {
|
|
509
|
+
fn();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.mutate(fn, {
|
|
513
|
+
source: options.source ?? 'api',
|
|
514
|
+
label: options.label ?? 'transaction',
|
|
515
|
+
history: options.history !== false,
|
|
516
|
+
merge: false,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// -- commands ----------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
async executeCommand(id: string, payload?: unknown): Promise<void> {
|
|
523
|
+
this.assertAlive();
|
|
524
|
+
const command = this.commands.get(id);
|
|
525
|
+
if (!command) {
|
|
526
|
+
throw new AIComposerError('UNKNOWN_COMMAND', `No command registered for "${id}"`);
|
|
527
|
+
}
|
|
528
|
+
const context = { editor: this as AIComposer, state: this.getState(), payload };
|
|
529
|
+
if (command.canExecute && !command.canExecute(context)) return;
|
|
530
|
+
this.events.emit('commandExecute', { id, payload });
|
|
531
|
+
await command.execute(context);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
canExecuteCommand(id: string, payload?: unknown): boolean {
|
|
535
|
+
const command = this.commands.get(id);
|
|
536
|
+
if (!command) return false;
|
|
537
|
+
const context = { editor: this as AIComposer, state: this.getState(), payload };
|
|
538
|
+
return command.canExecute ? command.canExecute(context) : true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// -- triggers ------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
getActiveTrigger(): TriggerState | null {
|
|
544
|
+
return this.triggerEngine.activeTrigger;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async acceptSuggestion(item?: SuggestionItem): Promise<void> {
|
|
548
|
+
await this.triggerEngine.accept(item);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
moveSuggestionSelection(delta: number): void {
|
|
552
|
+
this.triggerEngine.moveSelection(delta);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
closeTrigger(reason: TriggerCloseReason = 'manual'): void {
|
|
556
|
+
this.triggerEngine.close(reason);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
openTrigger(triggerId: string): void {
|
|
560
|
+
this.assertAlive();
|
|
561
|
+
const trigger = this.triggers.get(triggerId);
|
|
562
|
+
if (!trigger) {
|
|
563
|
+
throw new AIComposerError('UNKNOWN_TRIGGER', `No trigger registered for "${triggerId}"`);
|
|
564
|
+
}
|
|
565
|
+
this.insertText(trigger.character, this.selection.focus);
|
|
566
|
+
if (this.view) this.view.focus();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// -- plugins ---------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
registerPlugin(plugin: AIComposerPlugin): Unsubscribe {
|
|
572
|
+
this.assertAlive();
|
|
573
|
+
return this.pluginRegistry.install(plugin, this.createPluginContext());
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
registerTrigger(trigger: AIComposerTrigger): Unsubscribe {
|
|
577
|
+
return this.triggers.register(trigger);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
registerCommand(command: AIComposerCommand): Unsubscribe {
|
|
581
|
+
return this.commands.register(command);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
registerNodeType(definition: NodeDefinition): Unsubscribe {
|
|
585
|
+
return this.nodes.register(definition);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
registerSerializer(serializer: Serializer): Unsubscribe {
|
|
589
|
+
return this.serializers.register(serializer);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// -- events ----------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
on<K extends AIComposerEventType>(
|
|
595
|
+
type: K,
|
|
596
|
+
handler: (event: AIComposerEventMap[K]) => void,
|
|
597
|
+
): Unsubscribe {
|
|
598
|
+
return this.events.on(type, handler);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
once<K extends AIComposerEventType>(
|
|
602
|
+
type: K,
|
|
603
|
+
handler: (event: AIComposerEventMap[K]) => void,
|
|
604
|
+
): Unsubscribe {
|
|
605
|
+
return this.events.once(type, handler);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
off<K extends AIComposerEventType>(type: K, handler: (event: AIComposerEventMap[K]) => void): void {
|
|
609
|
+
this.events.off(type, handler);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// -- state ----------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
getState(): AIComposerState {
|
|
615
|
+
if (!this.stateCache) {
|
|
616
|
+
this.stateCache = {
|
|
617
|
+
value: this.document,
|
|
618
|
+
focused: this.focused,
|
|
619
|
+
disabled: this.config.disabled ?? false,
|
|
620
|
+
readonly: this.config.readonly ?? false,
|
|
621
|
+
submitting: this.submitting,
|
|
622
|
+
selection: this.selection,
|
|
623
|
+
activeTrigger: this.triggerEngine.activeTrigger,
|
|
624
|
+
suggestions: this.triggerEngine.suggestions,
|
|
625
|
+
activeSuggestionIndex: this.triggerEngine.activeSuggestionIndex,
|
|
626
|
+
attachments: getNodesOfType<AttachmentNode>(this.document, 'attachment'),
|
|
627
|
+
canUndo: this.history.canUndo,
|
|
628
|
+
canRedo: this.history.canRedo,
|
|
629
|
+
mode: this.config.mode ?? 'default',
|
|
630
|
+
placeholder: this.config.placeholder ?? '',
|
|
631
|
+
empty: isEmptyDocument(this.document),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return this.stateCache;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
subscribe(listener: StateListener): Unsubscribe {
|
|
638
|
+
this.stateListeners.add(listener);
|
|
639
|
+
return () => this.stateListeners.delete(listener);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// -- config ----------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
getConfig(): AIComposerConfig {
|
|
645
|
+
return this.config;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
configure(patch: Partial<AIComposerOptions>): void {
|
|
649
|
+
const previousMode = this.config.mode;
|
|
650
|
+
this.config = { ...this.config, ...patch };
|
|
651
|
+
if (patch.history) this.history.setOptions(patch.history);
|
|
652
|
+
this.invalidateState();
|
|
653
|
+
this.notify();
|
|
654
|
+
if (patch.mode !== undefined && patch.mode !== previousMode) {
|
|
655
|
+
this.events.emit('modeChange', { mode: patch.mode });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
setMode(mode: string): void {
|
|
660
|
+
if (this.config.mode === mode) return;
|
|
661
|
+
this.configure({ mode });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
setDisabled(disabled: boolean): void {
|
|
665
|
+
if ((this.config.disabled ?? false) === disabled) return;
|
|
666
|
+
this.configure({ disabled });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
setReadonly(readonly: boolean): void {
|
|
670
|
+
if ((this.config.readonly ?? false) === readonly) return;
|
|
671
|
+
this.configure({ readonly });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// -- serialization -----------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
serialize(format: SerializationFormat): unknown {
|
|
677
|
+
return this.serializers.serialize(format, this.document, { nodes: this.nodes });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// -- internals ----------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
private assertAlive(): void {
|
|
683
|
+
if (this.destroyed) {
|
|
684
|
+
throw new AIComposerError(ErrorCode.EDITOR_DESTROYED, 'This editor has been destroyed');
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private deleteSelectionIfNeeded(): void {
|
|
689
|
+
const { start, end } = getRange(this.selection);
|
|
690
|
+
if (start.nodeIndex === end.nodeIndex && start.offset === end.offset) return;
|
|
691
|
+
const result = deleteDocumentRange(this.document, { start, end });
|
|
692
|
+
this.document = result.document;
|
|
693
|
+
this.selection = createSelection(result.caret);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private mutate(fn: () => void, options: MutationOptions): void {
|
|
697
|
+
if (this.mutationDepth > 0) {
|
|
698
|
+
fn();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const previous = { document: this.document, selection: this.selection };
|
|
702
|
+
this.mutationDepth += 1;
|
|
703
|
+
try {
|
|
704
|
+
fn();
|
|
705
|
+
} finally {
|
|
706
|
+
this.mutationDepth -= 1;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Normalize (merge text runs) while keeping selection stable via global offsets.
|
|
710
|
+
const anchorOffset = toGlobalOffset(this.document, this.selection.anchor);
|
|
711
|
+
const focusOffset = toGlobalOffset(this.document, this.selection.focus);
|
|
712
|
+
this.document = {
|
|
713
|
+
nodes: normalizeNodes(this.document.nodes),
|
|
714
|
+
...(this.document.metadata ? { metadata: this.document.metadata } : {}),
|
|
715
|
+
};
|
|
716
|
+
this.selection = clampSelection(
|
|
717
|
+
this.document,
|
|
718
|
+
createSelection(
|
|
719
|
+
fromGlobalOffset(this.document, anchorOffset),
|
|
720
|
+
fromGlobalOffset(this.document, focusOffset),
|
|
721
|
+
),
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
if (options.history) {
|
|
725
|
+
this.history.record(previous, { label: options.label, merge: options.merge });
|
|
726
|
+
}
|
|
727
|
+
this.commit(options.source);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private commit(source: ChangeSource): void {
|
|
731
|
+
this.invalidateState();
|
|
732
|
+
this.notify();
|
|
733
|
+
this.events.emit('change', { value: this.document, source });
|
|
734
|
+
if (source === 'user') {
|
|
735
|
+
this.events.emit('input', { value: this.document, source });
|
|
736
|
+
}
|
|
737
|
+
this.triggerEngine.handleDocumentChange();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private restore(snapshot: { document: AIComposerDocument; selection: SelectionState }, source: 'undo' | 'redo'): void {
|
|
741
|
+
this.document = snapshot.document;
|
|
742
|
+
this.selection = clampSelection(snapshot.document, snapshot.selection);
|
|
743
|
+
this.invalidateState();
|
|
744
|
+
this.notify();
|
|
745
|
+
this.events.emit('change', { value: this.document, source });
|
|
746
|
+
this.view?.render?.();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private invalidateState(): void {
|
|
750
|
+
this.stateCache = null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private notify(): void {
|
|
754
|
+
const state = this.getState();
|
|
755
|
+
for (const listener of [...this.stateListeners]) {
|
|
756
|
+
try {
|
|
757
|
+
listener(state);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
console.error('[ai-composer] state listener failed', error);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private createTriggerHost(): TriggerHost {
|
|
765
|
+
// Intentional this-alias: the host exposes getters bound to this instance.
|
|
766
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
767
|
+
const editor = this;
|
|
768
|
+
return {
|
|
769
|
+
get editor() {
|
|
770
|
+
return editor as AIComposer;
|
|
771
|
+
},
|
|
772
|
+
get document() {
|
|
773
|
+
return editor.document;
|
|
774
|
+
},
|
|
775
|
+
get selection() {
|
|
776
|
+
return editor.selection;
|
|
777
|
+
},
|
|
778
|
+
get triggers() {
|
|
779
|
+
return editor.triggers;
|
|
780
|
+
},
|
|
781
|
+
get events() {
|
|
782
|
+
return editor.events;
|
|
783
|
+
},
|
|
784
|
+
patchState() {
|
|
785
|
+
// State getters read the engine live — just invalidate + notify.
|
|
786
|
+
editor.invalidateState();
|
|
787
|
+
editor.notify();
|
|
788
|
+
},
|
|
789
|
+
replaceRun(state: TriggerState, nodes: AIComposerNode[], options?: { trailingSpace?: boolean; source?: ChangeSource }) {
|
|
790
|
+
const range: DocumentRange = {
|
|
791
|
+
start: createPosition(state.nodeIndex, state.triggerOffset),
|
|
792
|
+
end: createPosition(state.nodeIndex, state.endOffset),
|
|
793
|
+
};
|
|
794
|
+
editor.replaceRange(range, nodes, options);
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private createPluginContext(): PluginContext {
|
|
800
|
+
return {
|
|
801
|
+
editor: this as AIComposer,
|
|
802
|
+
getConfig: () => this.config,
|
|
803
|
+
commands: this.commands,
|
|
804
|
+
triggers: this.triggers,
|
|
805
|
+
nodes: this.nodes,
|
|
806
|
+
serializers: this.serializers,
|
|
807
|
+
events: this.events,
|
|
808
|
+
onCleanup: () => undefined,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function documentLengthOf(document: AIComposerDocument): number {
|
|
814
|
+
return document.nodes.reduce((sum, node) => sum + (node.type === 'text' ? node.text.length : 1), 0);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Runtime guard for values claimed to be editors (useful in adapters). */
|
|
818
|
+
export function isAIComposer(value: unknown): value is AIComposer {
|
|
819
|
+
return (
|
|
820
|
+
typeof value === 'object' &&
|
|
821
|
+
value !== null &&
|
|
822
|
+
typeof (value as AIComposer).getValue === 'function' &&
|
|
823
|
+
typeof (value as AIComposer).setValue === 'function' &&
|
|
824
|
+
typeof (value as AIComposer).subscribe === 'function'
|
|
825
|
+
);
|
|
826
|
+
}
|