@atlaskit/editor-plugin-autocomplete 0.1.0 → 0.2.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/afm-cc/tsconfig.json +5 -1
  3. package/afm-jira/tsconfig.json +5 -1
  4. package/afm-products/tsconfig.json +5 -1
  5. package/build/tsconfig.json +24 -0
  6. package/dist/cjs/autocompletePlugin.js +18 -5
  7. package/dist/cjs/autocompletePluginType.js +5 -1
  8. package/dist/cjs/pm-plugins/autocomplete-plugin.js +361 -0
  9. package/dist/cjs/pm-plugins/ghost-text-decoration.js +39 -0
  10. package/dist/cjs/pm-plugins/scoring-pipeline.js +258 -0
  11. package/dist/cjs/pm-plugins/slow-lane-client.js +197 -0
  12. package/dist/cjs/pm-plugins/text-predictor.js +786 -0
  13. package/dist/es2019/autocompletePlugin.js +20 -6
  14. package/dist/es2019/autocompletePluginType.js +1 -0
  15. package/dist/es2019/pm-plugins/autocomplete-plugin.js +360 -0
  16. package/dist/es2019/pm-plugins/ghost-text-decoration.js +33 -0
  17. package/dist/es2019/pm-plugins/scoring-pipeline.js +224 -0
  18. package/dist/es2019/pm-plugins/slow-lane-client.js +154 -0
  19. package/dist/es2019/pm-plugins/text-predictor.js +624 -0
  20. package/dist/esm/autocompletePlugin.js +18 -5
  21. package/dist/esm/autocompletePluginType.js +1 -0
  22. package/dist/esm/pm-plugins/autocomplete-plugin.js +354 -0
  23. package/dist/esm/pm-plugins/ghost-text-decoration.js +33 -0
  24. package/dist/esm/pm-plugins/scoring-pipeline.js +255 -0
  25. package/dist/esm/pm-plugins/slow-lane-client.js +190 -0
  26. package/dist/esm/pm-plugins/text-predictor.js +783 -0
  27. package/dist/types/autocompletePluginType.d.ts +6 -3
  28. package/dist/types/pm-plugins/autocomplete-plugin.d.ts +36 -0
  29. package/dist/types/pm-plugins/ghost-text-decoration.d.ts +7 -0
  30. package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -0
  31. package/dist/types/pm-plugins/slow-lane-client.d.ts +46 -0
  32. package/dist/types/pm-plugins/text-predictor.d.ts +90 -0
  33. package/dist/types-ts4.5/autocompletePluginType.d.ts +6 -3
  34. package/dist/types-ts4.5/pm-plugins/autocomplete-plugin.d.ts +36 -0
  35. package/dist/types-ts4.5/pm-plugins/ghost-text-decoration.d.ts +7 -0
  36. package/dist/types-ts4.5/pm-plugins/scoring-pipeline.d.ts +33 -0
  37. package/dist/types-ts4.5/pm-plugins/slow-lane-client.d.ts +46 -0
  38. package/dist/types-ts4.5/pm-plugins/text-predictor.d.ts +90 -0
  39. package/package.json +2 -2
  40. package/src/autocompletePlugin.tsx +25 -5
  41. package/src/autocompletePluginType.ts +14 -3
  42. package/src/pm-plugins/autocomplete-plugin/package.json +15 -0
  43. package/src/pm-plugins/autocomplete-plugin.ts +429 -0
  44. package/src/pm-plugins/ghost-text-decoration.ts +44 -0
  45. package/src/pm-plugins/scoring-pipeline.ts +297 -0
  46. package/src/pm-plugins/slow-lane-client/package.json +15 -0
  47. package/src/pm-plugins/slow-lane-client.ts +220 -0
  48. package/src/pm-plugins/text-predictor/package.json +15 -0
  49. package/src/pm-plugins/text-predictor.ts +771 -0
  50. package/src/pm-plugins/typings.d.ts +12 -0
  51. package/tsconfig.app.json +10 -2
  52. package/tsconfig.json +3 -1
@@ -0,0 +1,429 @@
1
+ import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
2
+ import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
3
+ import { PluginKey } from '@atlaskit/editor-prosemirror/state';
4
+ import type { EditorState, ReadonlyTransaction, Transaction } from '@atlaskit/editor-prosemirror/state';
5
+ import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
6
+ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
7
+
8
+ import { createGhostTextDecorationSet } from './ghost-text-decoration';
9
+ import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
10
+ import {
11
+ predict,
12
+ loadDefaultVocabulary,
13
+ loadVectorsAsync,
14
+ incrementSessionFreq,
15
+ ingestDocumentPage,
16
+ } from './text-predictor';
17
+
18
+ const SLOW_LANE_ENDPOINT = '/gateway/api/v1/autocomplete/typeahead-encodings';
19
+
20
+ export const autocompletePluginKey = new PluginKey('autocomplete');
21
+
22
+ const DEBOUNCE_MS = 150;
23
+
24
+ export interface AutocompletePluginState {
25
+ /** The decoration set containing the ghost text widget */
26
+ decorationSet: DecorationSet;
27
+ /** The document position where the ghost text should appear */
28
+ ghostPosition: number;
29
+ /** The predicted ghost text to display */
30
+ ghostText: string;
31
+ }
32
+
33
+ const createInitialState = (): AutocompletePluginState => ({
34
+ ghostText: '',
35
+ ghostPosition: -1,
36
+ decorationSet: DecorationSet.empty,
37
+ });
38
+
39
+ /**
40
+ * Extract text content before the cursor from the current document.
41
+ * Returns the last ~200 characters for context.
42
+ */
43
+ const getTextBeforeCursor = (state: EditorState): string => {
44
+ const { $from } = state.selection;
45
+ const maxChars = 200;
46
+
47
+ // 1. Get the perfectly flattened text of the current block up to the cursor
48
+ const blockNode = $from.parent;
49
+ const offsetInBlock = $from.parentOffset;
50
+ const blockText = blockNode.textContent.slice(0, offsetInBlock);
51
+
52
+ if (blockText.length >= maxChars) {
53
+ return blockText.slice(-maxChars);
54
+ }
55
+
56
+ let fullText = blockText;
57
+
58
+ // 2. Walk backwards through previous blocks
59
+ let depth = $from.depth - 1;
60
+
61
+ while (fullText.length < maxChars && depth >= 0) {
62
+ const parentNode = $from.node(depth);
63
+ const indexInParent = $from.index(depth);
64
+
65
+ for (let i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
66
+ const sibling = parentNode.child(i);
67
+ const siblingText = sibling.textContent;
68
+ fullText = siblingText + '\n' + fullText;
69
+ }
70
+ depth--;
71
+ }
72
+
73
+ return fullText.slice(-maxChars);
74
+ };
75
+
76
+ /**
77
+ * Set the autocomplete state via a transaction metadata.
78
+ */
79
+ const setAutocompleteMeta = (
80
+ tr: Transaction,
81
+ meta: Partial<AutocompletePluginState>,
82
+ ): Transaction => {
83
+ return tr.setMeta(autocompletePluginKey, meta);
84
+ };
85
+
86
+ /**
87
+ * Apply a ghost text suggestion to the editor state.
88
+ */
89
+ const showGhostText = (view: EditorView, text: string, position: number): void => {
90
+ const { state, dispatch } = view;
91
+ const decorationSet = createGhostTextDecorationSet(state, position, text);
92
+ const tr = setAutocompleteMeta(state.tr, {
93
+ ghostText: text,
94
+ ghostPosition: position,
95
+ decorationSet,
96
+ });
97
+ dispatch(tr);
98
+ };
99
+
100
+ /**
101
+ * Clear the current ghost text from the editor.
102
+ */
103
+ const clearGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => {
104
+ const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
105
+ if (!pluginState || !pluginState.ghostText) {
106
+ return false;
107
+ }
108
+
109
+ if (dispatch) {
110
+ const tr = setAutocompleteMeta(state.tr, {
111
+ ghostText: '',
112
+ ghostPosition: -1,
113
+ decorationSet: DecorationSet.empty,
114
+ });
115
+ dispatch(tr);
116
+ }
117
+ return true;
118
+ };
119
+
120
+ /**
121
+ * Accept the current ghost text suggestion and insert it into the document.
122
+ */
123
+ const acceptGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => {
124
+ const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
125
+ if (!pluginState || !pluginState.ghostText) {
126
+ return false;
127
+ }
128
+
129
+ if (dispatch) {
130
+ const { ghostText, ghostPosition } = pluginState;
131
+ let tr = state.tr.insertText(ghostText, ghostPosition);
132
+ tr = setAutocompleteMeta(tr, {
133
+ ghostText: '',
134
+ ghostPosition: -1,
135
+ decorationSet: DecorationSet.empty,
136
+ });
137
+ dispatch(tr);
138
+ }
139
+ return true;
140
+ };
141
+
142
+ /**
143
+ * Context provided to the autocomplete plugin on first editor focus.
144
+ * Text fields are selectively ingested to boost word-frequency scoring for
145
+ * predictions, giving words already present in the document/thread an L1
146
+ * priority boost.
147
+ */
148
+ export interface AutocompleteContext {
149
+ /** Full page content as a string (e.g. markdown). */
150
+ fullPageContent?: string;
151
+ /** The currently selected text on the page, if any. */
152
+ pageSelectionContent?: string;
153
+ /** Content of the parent comment when the editor is in reply or edit mode. */
154
+ parentCommentContent?: string;
155
+ /** Contents of sibling comments when the editor is in reply or edit mode. */
156
+ siblingCommentsContents?: string[];
157
+ }
158
+
159
+ export interface AutocompletePluginOptions {
160
+ /**
161
+ * Async function called once on first editor focus to retrieve context for
162
+ * word-frequency boosting. Called lazily so the preset can remain synchronous.
163
+ */
164
+ getContext?: () => Promise<AutocompleteContext | undefined>;
165
+ }
166
+
167
+ /**
168
+ * Build the text payload for the slow-lane request by prepending any available
169
+ * comment context ahead of the live document text. This gives the backend
170
+ * model richer context about the thread the user is writing in.
171
+ */
172
+ const buildSlowLaneText = (docText: string, context?: AutocompleteContext): string => {
173
+ const lines: string[] = [];
174
+
175
+ if (context?.parentCommentContent) {
176
+ lines.push(`comment: ${context.parentCommentContent}`);
177
+ }
178
+
179
+ context?.siblingCommentsContents?.forEach((sibling, index) => {
180
+ lines.push(`reply ${index + 1}: ${sibling}`);
181
+ });
182
+
183
+ const nextReplyNumber = (context?.siblingCommentsContents?.length ?? 0) + 1;
184
+ lines.push(`reply ${nextReplyNumber}: ${docText}`);
185
+
186
+ return lines.join('\n');
187
+ };
188
+
189
+ export const createAutocompletePlugin = (options?: AutocompletePluginOptions) => {
190
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
191
+ let hasIngestedPage = false;
192
+ let resolvedContext: AutocompleteContext | undefined;
193
+ /**
194
+ * Set after accepting a suggestion so the next doc-change update
195
+ * skips scheduling a new prediction for the just-inserted text.
196
+ * Scoped to the factory so multiple editor instances don't share state.
197
+ */
198
+ let justAccepted = false;
199
+
200
+ /**
201
+ * Stores the text-before-cursor snapshot at the moment the user dismissed
202
+ * a suggestion via Escape. While the context remains identical, we suppress
203
+ * re-showing the same suggestion. Resets to null as soon as the text changes.
204
+ */
205
+ let dismissedContext: string | null = null;
206
+
207
+ const slowLaneClient = createSlowLaneClient({
208
+ baseUrl: '',
209
+ endpoint: SLOW_LANE_ENDPOINT,
210
+ });
211
+ setDefaultSlowLaneClient(slowLaneClient);
212
+
213
+ /**
214
+ * Schedule a prediction after a short debounce.
215
+ * Tier 1 predictions are synchronous (<0.1ms) but we still debounce
216
+ * to avoid unnecessary work on rapid keystrokes.
217
+ */
218
+ const schedulePrediction = (view: EditorView): void => {
219
+ if (debounceTimer) {
220
+ clearTimeout(debounceTimer);
221
+ }
222
+
223
+ debounceTimer = setTimeout(() => {
224
+ const { state } = view;
225
+ const { selection } = state;
226
+
227
+ // Only predict for cursor selections (not range selections)
228
+ if (!selection.empty) {
229
+ return;
230
+ }
231
+
232
+ const textBefore = getTextBeforeCursor(state);
233
+
234
+ // Suppress re-showing the same suggestion the user just dismissed.
235
+ // Once the text context changes (user types or deletes), this clears automatically.
236
+ if (textBefore === dismissedContext) {
237
+ return;
238
+ }
239
+ dismissedContext = null;
240
+
241
+ // Don't predict if there's not enough context
242
+ if (textBefore.trim().length < 3) {
243
+ return;
244
+ }
245
+
246
+ // Tier 1 prediction is synchronous -- no async needed
247
+ const prediction = predict(textBefore);
248
+
249
+ if (prediction && prediction.length > 0) {
250
+ showGhostText(view, prediction, selection.from);
251
+ }
252
+ }, DEBOUNCE_MS);
253
+ };
254
+
255
+ const maybeUpdateSessionFrequency = (view: EditorView, prevState: EditorState): void => {
256
+ const newText = getTextBeforeCursor(view.state);
257
+ const prevText = getTextBeforeCursor(prevState);
258
+
259
+ if (newText.length <= prevText.length) {
260
+ return;
261
+ }
262
+
263
+ const lastChar = newText[newText.length - 1];
264
+ if (!/[\s.,;:!?]/u.test(lastChar)) {
265
+ return;
266
+ }
267
+
268
+ // Only fire if the previous state did not already end on a boundary,
269
+ // so we don't double-count when multiple boundary chars are inserted.
270
+ const prevLastChar = prevText[prevText.length - 1];
271
+ if (prevLastChar && /[\s.,;:!?]/u.test(prevLastChar)) {
272
+ return;
273
+ }
274
+
275
+ const beforeBoundary = newText.slice(0, -1).trimEnd();
276
+ const lastSpaceIdx = beforeBoundary.lastIndexOf(' ');
277
+ const completedWord = beforeBoundary.slice(lastSpaceIdx + 1).toLowerCase();
278
+
279
+ if (completedWord.length >= 2) {
280
+ incrementSessionFreq(completedWord);
281
+ }
282
+ };
283
+
284
+ return new SafePlugin({
285
+ key: autocompletePluginKey,
286
+
287
+ state: {
288
+ init: (): AutocompletePluginState => createInitialState(),
289
+ apply: (tr: ReadonlyTransaction, pluginState: AutocompletePluginState): AutocompletePluginState => {
290
+ const meta = tr.getMeta(autocompletePluginKey) as
291
+ | Partial<AutocompletePluginState>
292
+ | undefined;
293
+
294
+ if (meta) {
295
+ return { ...pluginState, ...meta };
296
+ }
297
+
298
+ // If the document changed, clear the ghost text
299
+ // (new prediction will be scheduled from view.update)
300
+ if (tr.docChanged) {
301
+ return {
302
+ ...pluginState,
303
+ ghostText: '',
304
+ ghostPosition: -1,
305
+ decorationSet: DecorationSet.empty,
306
+ };
307
+ }
308
+
309
+ // If selection changed without doc change, clear ghost text
310
+ if (tr.selectionSet && pluginState.ghostText) {
311
+ return {
312
+ ...pluginState,
313
+ ghostText: '',
314
+ ghostPosition: -1,
315
+ decorationSet: DecorationSet.empty,
316
+ };
317
+ }
318
+
319
+ return pluginState;
320
+ },
321
+ },
322
+
323
+ props: {
324
+ decorations: (state: EditorState) => {
325
+ const pluginState = autocompletePluginKey.getState(state) as
326
+ | AutocompletePluginState
327
+ | undefined;
328
+ return pluginState?.decorationSet ?? DecorationSet.empty;
329
+ },
330
+
331
+ handleKeyDown: keydownHandler({
332
+ Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
333
+ const accepted = acceptGhostText(state, dispatch);
334
+ if (accepted) justAccepted = true;
335
+ return accepted;
336
+ },
337
+ ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
338
+ const accepted = acceptGhostText(state, dispatch);
339
+ if (accepted) justAccepted = true;
340
+ return accepted;
341
+ },
342
+ Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
343
+ const didClear = clearGhostText(state, dispatch);
344
+ if (didClear) {
345
+ dismissedContext = getTextBeforeCursor(state);
346
+ }
347
+ return didClear;
348
+ },
349
+ }),
350
+
351
+ handleDOMEvents: {
352
+ blur: (view: EditorView) => {
353
+ const pluginState = autocompletePluginKey.getState(view.state) as
354
+ | AutocompletePluginState
355
+ | undefined;
356
+ if (pluginState?.ghostText) {
357
+ clearGhostText(view.state, view.dispatch);
358
+ }
359
+ return false;
360
+ },
361
+ focus: () => {
362
+ loadDefaultVocabulary();
363
+ loadVectorsAsync().catch(() => {});
364
+ if (!hasIngestedPage) {
365
+ hasIngestedPage = true;
366
+ if (options?.getContext) {
367
+ options
368
+ .getContext()
369
+ .then((context) => {
370
+ if (!context) {
371
+ return;
372
+ }
373
+ resolvedContext = context;
374
+
375
+ if (context.fullPageContent) {
376
+ ingestDocumentPage(context.fullPageContent);
377
+ }
378
+ if (context.parentCommentContent) {
379
+ ingestDocumentPage(context.parentCommentContent);
380
+ }
381
+ context.siblingCommentsContents?.forEach(ingestDocumentPage);
382
+ })
383
+ .catch(() => {});
384
+ }
385
+ }
386
+ return false;
387
+ },
388
+ },
389
+ },
390
+
391
+ view: () => ({
392
+ update: (view: EditorView, prevState: EditorState) => {
393
+ if (!prevState.doc.eq(view.state.doc)) {
394
+ if (justAccepted) {
395
+ justAccepted = false;
396
+
397
+ // ✨ THE FIX: Memorize the text state right after acceptance.
398
+ // Any follow-up transactions will hit the 'dismissedContext'
399
+ // block and abort until the user actually types a new character!
400
+ dismissedContext = getTextBeforeCursor(view.state);
401
+
402
+ // Also clear any pending debounce timers from before the acceptance
403
+ if (debounceTimer) {
404
+ clearTimeout(debounceTimer);
405
+ }
406
+ return;
407
+ }
408
+
409
+ maybeUpdateSessionFrequency(view, prevState);
410
+
411
+ const textBefore = getTextBeforeCursor(view.state);
412
+ if (isWordBoundary(textBefore)) {
413
+ slowLaneClient.updateContext(
414
+ buildSlowLaneText(view.state.doc.textContent, resolvedContext),
415
+ );
416
+ }
417
+
418
+ schedulePrediction(view);
419
+ }
420
+ },
421
+ destroy: () => {
422
+ if (debounceTimer) {
423
+ clearTimeout(debounceTimer);
424
+ }
425
+ setDefaultSlowLaneClient(null);
426
+ },
427
+ }),
428
+ });
429
+ };
@@ -0,0 +1,44 @@
1
+ import type { EditorState } from '@atlaskit/editor-prosemirror/state';
2
+ import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
3
+
4
+ const GHOST_TEXT_CLASS = 'autocomplete-ghost-text';
5
+
6
+ /**
7
+ * Creates a DecorationSet containing a ghost text widget at the given position.
8
+ * The ghost text is rendered as a styled <span> that appears after the cursor.
9
+ */
10
+ export const createGhostTextDecorationSet = (
11
+ state: EditorState,
12
+ position: number,
13
+ text: string,
14
+ ): DecorationSet => {
15
+ if (!text) {
16
+ return DecorationSet.empty;
17
+ }
18
+
19
+ const decoration = Decoration.widget(
20
+ position,
21
+ () => {
22
+ const container = document.createElement('span');
23
+ container.className = GHOST_TEXT_CLASS;
24
+ container.setAttribute('data-autocomplete-ghost', 'true');
25
+ container.style.color = '#999';
26
+ container.style.opacity = '0.6';
27
+ container.style.pointerEvents = 'none';
28
+ container.style.userSelect = 'none';
29
+ container.style.fontStyle = 'italic';
30
+ // U+200B (Zero Width Space) gives the browser a line-break opportunity
31
+ // immediately before the ghost text. This ensures the typed text before
32
+ // the span is never pushed to the next line by the ghost text's width —
33
+ // only the ghost text itself will wrap if it doesn't fit.
34
+ container.textContent = '\u200b' + text;
35
+ return container;
36
+ },
37
+ {
38
+ side: 1, // Render after content at this position
39
+ key: 'autocomplete-ghost-text',
40
+ },
41
+ );
42
+
43
+ return DecorationSet.create(state.doc, [decoration]);
44
+ };