@atlaskit/editor-plugin-autocomplete 4.1.2 → 4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @atlaskit/editor-plugin-autocomplete
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`268f447fd26f4`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/268f447fd26f4) -
8
+ Fix autocomplete click interactions in the editor
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies
13
+
14
+ ## 4.1.3
15
+
16
+ ### Patch Changes
17
+
18
+ - [`5cd6c10a9394f`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/5cd6c10a9394f) -
19
+ Fix autocomplete context extraction when inline formatting inserts leaf nodes before the cursor,
20
+ preventing misaligned ghost-text suggestions that can surface as stray spaces.
21
+
3
22
  ## 4.1.2
4
23
 
5
24
  ### Patch Changes
@@ -38,6 +38,7 @@ var MAX_INGESTED_CONTEXT_TEXTS = 50;
38
38
  // non-comment editors (where parentCommentContent never arrives) don't refetch
39
39
  // on every word boundary for the plugin's lifetime.
40
40
  var MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
41
+ var INLINE_LEAF_TEXT = ' ';
41
42
 
42
43
  // eslint-disable-next-line require-unicode-regexp
43
44
  var TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
@@ -63,7 +64,8 @@ var getTextBeforeCursor = function getTextBeforeCursor(state) {
63
64
  var maxChars = 200;
64
65
  var blockNode = $from.parent;
65
66
  var offsetInBlock = $from.parentOffset;
66
- var blockText = blockNode.textContent.slice(0, offsetInBlock);
67
+ // PM offsets count inline leaf nodes; textContent indices do not.
68
+ var blockText = blockNode.textBetween(0, offsetInBlock, undefined, INLINE_LEAF_TEXT);
67
69
  if (blockText.length >= maxChars) {
68
70
  return blockText.slice(-maxChars);
69
71
  }
@@ -76,7 +78,7 @@ var getTextBeforeCursor = function getTextBeforeCursor(state) {
76
78
  var indexInParent = $from.index(depth);
77
79
  for (var i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
78
80
  var sibling = parentNode.child(i);
79
- var siblingText = sibling.textContent;
81
+ var siblingText = sibling.textBetween(0, sibling.content.size, '\n', INLINE_LEAF_TEXT);
80
82
  fullText = siblingText + '\n' + fullText;
81
83
  }
82
84
  depth--;
@@ -167,6 +169,16 @@ var acceptGhostText = function acceptGhostText(state, dispatch) {
167
169
  }
168
170
  return true;
169
171
  };
172
+ var acceptGhostTextWithAnalytics = function acceptGhostTextWithAnalytics(state, dispatch, onAccepted) {
173
+ var _pluginState$ghostTex;
174
+ var pluginState = autocompletePluginKey.getState(state);
175
+ var ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
176
+ var accepted = acceptGhostText(state, dispatch);
177
+ if (accepted) {
178
+ onAccepted(ghostText);
179
+ }
180
+ return accepted;
181
+ };
170
182
 
171
183
  /**
172
184
  * Context provided to the autocomplete plugin on first editor focus.
@@ -607,28 +619,18 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
607
619
  },
608
620
  handleKeyDown: (0, _keymap.keydownHandler)({
609
621
  Tab: function Tab(state, dispatch) {
610
- var _pluginState$ghostTex;
611
- var pluginState = autocompletePluginKey.getState(state);
612
- var ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
613
- var accepted = acceptGhostText(state, dispatch);
614
- if (accepted) {
622
+ return acceptGhostTextWithAnalytics(state, dispatch, function (ghostText) {
615
623
  justAccepted = true;
616
624
  lastShownGhostText = '';
617
625
  fireSuggestionInsertedAnalytics(ghostText);
618
- }
619
- return accepted;
626
+ });
620
627
  },
621
628
  ArrowRight: function ArrowRight(state, dispatch) {
622
- var _pluginState$ghostTex2;
623
- var pluginState = autocompletePluginKey.getState(state);
624
- var ghostText = (_pluginState$ghostTex2 = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex2 !== void 0 ? _pluginState$ghostTex2 : '';
625
- var accepted = acceptGhostText(state, dispatch);
626
- if (accepted) {
629
+ return acceptGhostTextWithAnalytics(state, dispatch, function (ghostText) {
627
630
  justAccepted = true;
628
631
  lastShownGhostText = '';
629
632
  fireSuggestionInsertedAnalytics(ghostText);
630
- }
631
- return accepted;
633
+ });
632
634
  },
633
635
  Escape: function Escape(state, dispatch) {
634
636
  var didClear = clearGhostText(state, dispatch);
@@ -651,6 +653,30 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
651
653
  }
652
654
  return false;
653
655
  },
656
+ mousedown: function mousedown(view, event) {
657
+ if (!isAutocompleteEnabled || !(event.target instanceof Element)) {
658
+ return false;
659
+ }
660
+ if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
661
+ var didClear = clearGhostText(view.state, view.dispatch);
662
+ if (didClear) {
663
+ dismissedContext = getTextBeforeCursor(view.state);
664
+ lastShownGhostText = '';
665
+ fireSuggestionDismissedAnalytics('click');
666
+ }
667
+ return false;
668
+ }
669
+ event.preventDefault();
670
+ var accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, function (ghostText) {
671
+ justAccepted = true;
672
+ lastShownGhostText = '';
673
+ fireSuggestionInsertedAnalytics(ghostText);
674
+ });
675
+ if (accepted) {
676
+ view.focus();
677
+ }
678
+ return accepted;
679
+ },
654
680
  focus: function focus() {
655
681
  var _options$useLocalMode, _options$useLocalMode2;
656
682
  if (!isAutocompleteEnabled) {
@@ -19,10 +19,13 @@ var createGhostTextDecorationSet = exports.createGhostTextDecorationSet = functi
19
19
  var container = document.createElement('span');
20
20
  container.className = GHOST_TEXT_CLASS;
21
21
  container.setAttribute('data-autocomplete-ghost', 'true');
22
+ container.setAttribute('contenteditable', 'false');
23
+ container.setAttribute('aria-hidden', 'true');
22
24
  container.style.color = '#999';
23
25
  container.style.opacity = '0.6';
24
- container.style.pointerEvents = 'none';
26
+ container.style.pointerEvents = 'auto';
25
27
  container.style.userSelect = 'none';
28
+ container.style.cursor = 'pointer';
26
29
  container.style.fontStyle = 'italic';
27
30
  // U+200B (Zero Width Space) gives the browser a line-break opportunity
28
31
  // immediately before the ghost text. This ensures the typed text before
@@ -24,6 +24,7 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
24
24
  // non-comment editors (where parentCommentContent never arrives) don't refetch
25
25
  // on every word boundary for the plugin's lifetime.
26
26
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
27
+ const INLINE_LEAF_TEXT = ' ';
27
28
 
28
29
  // eslint-disable-next-line require-unicode-regexp
29
30
  const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
@@ -47,7 +48,8 @@ const getTextBeforeCursor = state => {
47
48
  const maxChars = 200;
48
49
  const blockNode = $from.parent;
49
50
  const offsetInBlock = $from.parentOffset;
50
- const blockText = blockNode.textContent.slice(0, offsetInBlock);
51
+ // PM offsets count inline leaf nodes; textContent indices do not.
52
+ const blockText = blockNode.textBetween(0, offsetInBlock, undefined, INLINE_LEAF_TEXT);
51
53
  if (blockText.length >= maxChars) {
52
54
  return blockText.slice(-maxChars);
53
55
  }
@@ -60,7 +62,7 @@ const getTextBeforeCursor = state => {
60
62
  const indexInParent = $from.index(depth);
61
63
  for (let i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
62
64
  const sibling = parentNode.child(i);
63
- const siblingText = sibling.textContent;
65
+ const siblingText = sibling.textBetween(0, sibling.content.size, '\n', INLINE_LEAF_TEXT);
64
66
  fullText = siblingText + '\n' + fullText;
65
67
  }
66
68
  depth--;
@@ -155,6 +157,16 @@ const acceptGhostText = (state, dispatch) => {
155
157
  }
156
158
  return true;
157
159
  };
160
+ const acceptGhostTextWithAnalytics = (state, dispatch, onAccepted) => {
161
+ var _pluginState$ghostTex;
162
+ const pluginState = autocompletePluginKey.getState(state);
163
+ const ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
164
+ const accepted = acceptGhostText(state, dispatch);
165
+ if (accepted) {
166
+ onAccepted(ghostText);
167
+ }
168
+ return accepted;
169
+ };
158
170
 
159
171
  /**
160
172
  * Context provided to the autocomplete plugin on first editor focus.
@@ -594,28 +606,18 @@ export const createAutocompletePlugin = (options, api) => {
594
606
  },
595
607
  handleKeyDown: keydownHandler({
596
608
  Tab: (state, dispatch) => {
597
- var _pluginState$ghostTex;
598
- const pluginState = autocompletePluginKey.getState(state);
599
- const ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
600
- const accepted = acceptGhostText(state, dispatch);
601
- if (accepted) {
609
+ return acceptGhostTextWithAnalytics(state, dispatch, ghostText => {
602
610
  justAccepted = true;
603
611
  lastShownGhostText = '';
604
612
  fireSuggestionInsertedAnalytics(ghostText);
605
- }
606
- return accepted;
613
+ });
607
614
  },
608
615
  ArrowRight: (state, dispatch) => {
609
- var _pluginState$ghostTex2;
610
- const pluginState = autocompletePluginKey.getState(state);
611
- const ghostText = (_pluginState$ghostTex2 = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex2 !== void 0 ? _pluginState$ghostTex2 : '';
612
- const accepted = acceptGhostText(state, dispatch);
613
- if (accepted) {
616
+ return acceptGhostTextWithAnalytics(state, dispatch, ghostText => {
614
617
  justAccepted = true;
615
618
  lastShownGhostText = '';
616
619
  fireSuggestionInsertedAnalytics(ghostText);
617
- }
618
- return accepted;
620
+ });
619
621
  },
620
622
  Escape: (state, dispatch) => {
621
623
  const didClear = clearGhostText(state, dispatch);
@@ -638,6 +640,30 @@ export const createAutocompletePlugin = (options, api) => {
638
640
  }
639
641
  return false;
640
642
  },
643
+ mousedown: (view, event) => {
644
+ if (!isAutocompleteEnabled || !(event.target instanceof Element)) {
645
+ return false;
646
+ }
647
+ if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
648
+ const didClear = clearGhostText(view.state, view.dispatch);
649
+ if (didClear) {
650
+ dismissedContext = getTextBeforeCursor(view.state);
651
+ lastShownGhostText = '';
652
+ fireSuggestionDismissedAnalytics('click');
653
+ }
654
+ return false;
655
+ }
656
+ event.preventDefault();
657
+ const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, ghostText => {
658
+ justAccepted = true;
659
+ lastShownGhostText = '';
660
+ fireSuggestionInsertedAnalytics(ghostText);
661
+ });
662
+ if (accepted) {
663
+ view.focus();
664
+ }
665
+ return accepted;
666
+ },
641
667
  focus: () => {
642
668
  var _options$useLocalMode, _options$useLocalMode2;
643
669
  if (!isAutocompleteEnabled) {
@@ -13,10 +13,13 @@ export const createGhostTextDecorationSet = (state, position, text) => {
13
13
  const container = document.createElement('span');
14
14
  container.className = GHOST_TEXT_CLASS;
15
15
  container.setAttribute('data-autocomplete-ghost', 'true');
16
+ container.setAttribute('contenteditable', 'false');
17
+ container.setAttribute('aria-hidden', 'true');
16
18
  container.style.color = '#999';
17
19
  container.style.opacity = '0.6';
18
- container.style.pointerEvents = 'none';
20
+ container.style.pointerEvents = 'auto';
19
21
  container.style.userSelect = 'none';
22
+ container.style.cursor = 'pointer';
20
23
  container.style.fontStyle = 'italic';
21
24
  // U+200B (Zero Width Space) gives the browser a line-break opportunity
22
25
  // immediately before the ghost text. This ensures the typed text before
@@ -31,6 +31,7 @@ var MAX_INGESTED_CONTEXT_TEXTS = 50;
31
31
  // non-comment editors (where parentCommentContent never arrives) don't refetch
32
32
  // on every word boundary for the plugin's lifetime.
33
33
  var MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
34
+ var INLINE_LEAF_TEXT = ' ';
34
35
 
35
36
  // eslint-disable-next-line require-unicode-regexp
36
37
  var TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
@@ -56,7 +57,8 @@ var getTextBeforeCursor = function getTextBeforeCursor(state) {
56
57
  var maxChars = 200;
57
58
  var blockNode = $from.parent;
58
59
  var offsetInBlock = $from.parentOffset;
59
- var blockText = blockNode.textContent.slice(0, offsetInBlock);
60
+ // PM offsets count inline leaf nodes; textContent indices do not.
61
+ var blockText = blockNode.textBetween(0, offsetInBlock, undefined, INLINE_LEAF_TEXT);
60
62
  if (blockText.length >= maxChars) {
61
63
  return blockText.slice(-maxChars);
62
64
  }
@@ -69,7 +71,7 @@ var getTextBeforeCursor = function getTextBeforeCursor(state) {
69
71
  var indexInParent = $from.index(depth);
70
72
  for (var i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
71
73
  var sibling = parentNode.child(i);
72
- var siblingText = sibling.textContent;
74
+ var siblingText = sibling.textBetween(0, sibling.content.size, '\n', INLINE_LEAF_TEXT);
73
75
  fullText = siblingText + '\n' + fullText;
74
76
  }
75
77
  depth--;
@@ -160,6 +162,16 @@ var acceptGhostText = function acceptGhostText(state, dispatch) {
160
162
  }
161
163
  return true;
162
164
  };
165
+ var acceptGhostTextWithAnalytics = function acceptGhostTextWithAnalytics(state, dispatch, onAccepted) {
166
+ var _pluginState$ghostTex;
167
+ var pluginState = autocompletePluginKey.getState(state);
168
+ var ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
169
+ var accepted = acceptGhostText(state, dispatch);
170
+ if (accepted) {
171
+ onAccepted(ghostText);
172
+ }
173
+ return accepted;
174
+ };
163
175
 
164
176
  /**
165
177
  * Context provided to the autocomplete plugin on first editor focus.
@@ -600,28 +612,18 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
600
612
  },
601
613
  handleKeyDown: keydownHandler({
602
614
  Tab: function Tab(state, dispatch) {
603
- var _pluginState$ghostTex;
604
- var pluginState = autocompletePluginKey.getState(state);
605
- var ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
606
- var accepted = acceptGhostText(state, dispatch);
607
- if (accepted) {
615
+ return acceptGhostTextWithAnalytics(state, dispatch, function (ghostText) {
608
616
  justAccepted = true;
609
617
  lastShownGhostText = '';
610
618
  fireSuggestionInsertedAnalytics(ghostText);
611
- }
612
- return accepted;
619
+ });
613
620
  },
614
621
  ArrowRight: function ArrowRight(state, dispatch) {
615
- var _pluginState$ghostTex2;
616
- var pluginState = autocompletePluginKey.getState(state);
617
- var ghostText = (_pluginState$ghostTex2 = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex2 !== void 0 ? _pluginState$ghostTex2 : '';
618
- var accepted = acceptGhostText(state, dispatch);
619
- if (accepted) {
622
+ return acceptGhostTextWithAnalytics(state, dispatch, function (ghostText) {
620
623
  justAccepted = true;
621
624
  lastShownGhostText = '';
622
625
  fireSuggestionInsertedAnalytics(ghostText);
623
- }
624
- return accepted;
626
+ });
625
627
  },
626
628
  Escape: function Escape(state, dispatch) {
627
629
  var didClear = clearGhostText(state, dispatch);
@@ -644,6 +646,30 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
644
646
  }
645
647
  return false;
646
648
  },
649
+ mousedown: function mousedown(view, event) {
650
+ if (!isAutocompleteEnabled || !(event.target instanceof Element)) {
651
+ return false;
652
+ }
653
+ if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
654
+ var didClear = clearGhostText(view.state, view.dispatch);
655
+ if (didClear) {
656
+ dismissedContext = getTextBeforeCursor(view.state);
657
+ lastShownGhostText = '';
658
+ fireSuggestionDismissedAnalytics('click');
659
+ }
660
+ return false;
661
+ }
662
+ event.preventDefault();
663
+ var accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, function (ghostText) {
664
+ justAccepted = true;
665
+ lastShownGhostText = '';
666
+ fireSuggestionInsertedAnalytics(ghostText);
667
+ });
668
+ if (accepted) {
669
+ view.focus();
670
+ }
671
+ return accepted;
672
+ },
647
673
  focus: function focus() {
648
674
  var _options$useLocalMode, _options$useLocalMode2;
649
675
  if (!isAutocompleteEnabled) {
@@ -13,10 +13,13 @@ export var createGhostTextDecorationSet = function createGhostTextDecorationSet(
13
13
  var container = document.createElement('span');
14
14
  container.className = GHOST_TEXT_CLASS;
15
15
  container.setAttribute('data-autocomplete-ghost', 'true');
16
+ container.setAttribute('contenteditable', 'false');
17
+ container.setAttribute('aria-hidden', 'true');
16
18
  container.style.color = '#999';
17
19
  container.style.opacity = '0.6';
18
- container.style.pointerEvents = 'none';
20
+ container.style.pointerEvents = 'auto';
19
21
  container.style.userSelect = 'none';
22
+ container.style.cursor = 'pointer';
20
23
  container.style.fontStyle = 'italic';
21
24
  // U+200B (Zero Width Space) gives the browser a line-break opportunity
22
25
  // immediately before the ghost text. This ensures the typed text before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "Client-side text autocomplete plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -30,7 +30,7 @@
30
30
  "wink-nlp": "^2.4.0"
31
31
  },
32
32
  "peerDependencies": {
33
- "@atlaskit/editor-common": "^116.30.0",
33
+ "@atlaskit/editor-common": "^116.40.0",
34
34
  "@atlaskit/editor-plugin-analytics": "^12.0.0",
35
35
  "react": "^18.2.0"
36
36
  },
@@ -47,6 +47,7 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
47
47
  // non-comment editors (where parentCommentContent never arrives) don't refetch
48
48
  // on every word boundary for the plugin's lifetime.
49
49
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
50
+ const INLINE_LEAF_TEXT = ' ';
50
51
 
51
52
  // eslint-disable-next-line require-unicode-regexp
52
53
  const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
@@ -82,7 +83,8 @@ const getTextBeforeCursor = (state: EditorState): string => {
82
83
 
83
84
  const blockNode = $from.parent;
84
85
  const offsetInBlock = $from.parentOffset;
85
- const blockText = blockNode.textContent.slice(0, offsetInBlock);
86
+ // PM offsets count inline leaf nodes; textContent indices do not.
87
+ const blockText = blockNode.textBetween(0, offsetInBlock, undefined, INLINE_LEAF_TEXT);
86
88
 
87
89
  if (blockText.length >= maxChars) {
88
90
  return blockText.slice(-maxChars);
@@ -99,7 +101,7 @@ const getTextBeforeCursor = (state: EditorState): string => {
99
101
 
100
102
  for (let i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
101
103
  const sibling = parentNode.child(i);
102
- const siblingText = sibling.textContent;
104
+ const siblingText = sibling.textBetween(0, sibling.content.size, '\n', INLINE_LEAF_TEXT);
103
105
  fullText = siblingText + '\n' + fullText;
104
106
  }
105
107
  depth--;
@@ -190,6 +192,20 @@ const acceptGhostText = (state: EditorState, dispatch?: (tr: Transaction) => voi
190
192
  return true;
191
193
  };
192
194
 
195
+ const acceptGhostTextWithAnalytics = (
196
+ state: EditorState,
197
+ dispatch: ((tr: Transaction) => void) | undefined,
198
+ onAccepted: (ghostText: string) => void,
199
+ ): boolean => {
200
+ const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
201
+ const ghostText = pluginState?.ghostText ?? '';
202
+ const accepted = acceptGhostText(state, dispatch);
203
+ if (accepted) {
204
+ onAccepted(ghostText);
205
+ }
206
+ return accepted;
207
+ };
208
+
193
209
  /**
194
210
  * Context provided to the autocomplete plugin on first editor focus.
195
211
  * Text fields are selectively ingested to boost word-frequency scoring for
@@ -376,7 +392,7 @@ export const createAutocompletePlugin = (
376
392
  return options?.useLocalModel ? 'localLlm' : 'server';
377
393
  };
378
394
 
379
- const fireSuggestionDismissedAnalytics = (reason: 'escape' | 'blur'): void => {
395
+ const fireSuggestionDismissedAnalytics = (reason: 'escape' | 'blur' | 'click'): void => {
380
396
  api?.analytics?.actions.fireAnalyticsEvent({
381
397
  action: ACTION.SUGGESTION_DISMISSED,
382
398
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
@@ -734,30 +750,18 @@ export const createAutocompletePlugin = (
734
750
 
735
751
  handleKeyDown: keydownHandler({
736
752
  Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
737
- const pluginState = autocompletePluginKey.getState(state) as
738
- | AutocompletePluginState
739
- | undefined;
740
- const ghostText = pluginState?.ghostText ?? '';
741
- const accepted = acceptGhostText(state, dispatch);
742
- if (accepted) {
753
+ return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
743
754
  justAccepted = true;
744
755
  lastShownGhostText = '';
745
756
  fireSuggestionInsertedAnalytics(ghostText);
746
- }
747
- return accepted;
757
+ });
748
758
  },
749
759
  ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
750
- const pluginState = autocompletePluginKey.getState(state) as
751
- | AutocompletePluginState
752
- | undefined;
753
- const ghostText = pluginState?.ghostText ?? '';
754
- const accepted = acceptGhostText(state, dispatch);
755
- if (accepted) {
760
+ return acceptGhostTextWithAnalytics(state, dispatch, (ghostText) => {
756
761
  justAccepted = true;
757
762
  lastShownGhostText = '';
758
763
  fireSuggestionInsertedAnalytics(ghostText);
759
- }
760
- return accepted;
764
+ });
761
765
  },
762
766
  Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
763
767
  const didClear = clearGhostText(state, dispatch);
@@ -784,6 +788,35 @@ export const createAutocompletePlugin = (
784
788
  }
785
789
  return false;
786
790
  },
791
+ mousedown: (view: EditorView, event: MouseEvent) => {
792
+ if (!isAutocompleteEnabled || !(event.target instanceof Element)) {
793
+ return false;
794
+ }
795
+
796
+ if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
797
+ const didClear = clearGhostText(view.state, view.dispatch);
798
+ if (didClear) {
799
+ dismissedContext = getTextBeforeCursor(view.state);
800
+ lastShownGhostText = '';
801
+ fireSuggestionDismissedAnalytics('click');
802
+ }
803
+ return false;
804
+ }
805
+
806
+ event.preventDefault();
807
+
808
+ const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (ghostText) => {
809
+ justAccepted = true;
810
+ lastShownGhostText = '';
811
+ fireSuggestionInsertedAnalytics(ghostText);
812
+ });
813
+
814
+ if (accepted) {
815
+ view.focus();
816
+ }
817
+
818
+ return accepted;
819
+ },
787
820
  focus: () => {
788
821
  if (!isAutocompleteEnabled) {
789
822
  return false;
@@ -22,10 +22,13 @@ export const createGhostTextDecorationSet = (
22
22
  const container = document.createElement('span');
23
23
  container.className = GHOST_TEXT_CLASS;
24
24
  container.setAttribute('data-autocomplete-ghost', 'true');
25
+ container.setAttribute('contenteditable', 'false');
26
+ container.setAttribute('aria-hidden', 'true');
25
27
  container.style.color = '#999';
26
28
  container.style.opacity = '0.6';
27
- container.style.pointerEvents = 'none';
29
+ container.style.pointerEvents = 'auto';
28
30
  container.style.userSelect = 'none';
31
+ container.style.cursor = 'pointer';
29
32
  container.style.fontStyle = 'italic';
30
33
  // U+200B (Zero Width Space) gives the browser a line-break opportunity
31
34
  // immediately before the ghost text. This ensures the typed text before