@milkdown/crepe 7.19.2 → 7.21.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 (209) hide show
  1. package/lib/cjs/builder.js +44 -1
  2. package/lib/cjs/builder.js.map +1 -1
  3. package/lib/cjs/feature/ai/index.js +1492 -0
  4. package/lib/cjs/feature/ai/index.js.map +1 -0
  5. package/lib/cjs/feature/block-edit/index.js +9 -2
  6. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  7. package/lib/cjs/feature/code-mirror/index.js +2 -0
  8. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  9. package/lib/cjs/feature/cursor/index.js +2 -0
  10. package/lib/cjs/feature/cursor/index.js.map +1 -1
  11. package/lib/cjs/feature/image-block/index.js +5 -1
  12. package/lib/cjs/feature/image-block/index.js.map +1 -1
  13. package/lib/cjs/feature/latex/index.js +7 -0
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +2 -0
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +2 -0
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +2 -0
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +2 -0
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +497 -5
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/feature/top-bar/index.js +791 -0
  26. package/lib/cjs/feature/top-bar/index.js.map +1 -0
  27. package/lib/cjs/index.js +2047 -160
  28. package/lib/cjs/index.js.map +1 -1
  29. package/lib/cjs/llm-providers/anthropic/index.js +147 -0
  30. package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
  31. package/lib/cjs/llm-providers/openai/index.js +138 -0
  32. package/lib/cjs/llm-providers/openai/index.js.map +1 -0
  33. package/lib/esm/builder.js +44 -1
  34. package/lib/esm/builder.js.map +1 -1
  35. package/lib/esm/feature/ai/index.js +1487 -0
  36. package/lib/esm/feature/ai/index.js.map +1 -0
  37. package/lib/esm/feature/block-edit/index.js +9 -2
  38. package/lib/esm/feature/block-edit/index.js.map +1 -1
  39. package/lib/esm/feature/code-mirror/index.js +2 -0
  40. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  41. package/lib/esm/feature/cursor/index.js +2 -0
  42. package/lib/esm/feature/cursor/index.js.map +1 -1
  43. package/lib/esm/feature/image-block/index.js +5 -1
  44. package/lib/esm/feature/image-block/index.js.map +1 -1
  45. package/lib/esm/feature/latex/index.js +7 -0
  46. package/lib/esm/feature/latex/index.js.map +1 -1
  47. package/lib/esm/feature/link-tooltip/index.js +2 -0
  48. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  49. package/lib/esm/feature/list-item/index.js +2 -0
  50. package/lib/esm/feature/list-item/index.js.map +1 -1
  51. package/lib/esm/feature/placeholder/index.js +2 -0
  52. package/lib/esm/feature/placeholder/index.js.map +1 -1
  53. package/lib/esm/feature/table/index.js +2 -0
  54. package/lib/esm/feature/table/index.js.map +1 -1
  55. package/lib/esm/feature/toolbar/index.js +499 -7
  56. package/lib/esm/feature/toolbar/index.js.map +1 -1
  57. package/lib/esm/feature/top-bar/index.js +789 -0
  58. package/lib/esm/feature/top-bar/index.js.map +1 -0
  59. package/lib/esm/index.js +2040 -153
  60. package/lib/esm/index.js.map +1 -1
  61. package/lib/esm/llm-providers/anthropic/index.js +145 -0
  62. package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
  63. package/lib/esm/llm-providers/openai/index.js +136 -0
  64. package/lib/esm/llm-providers/openai/index.js.map +1 -0
  65. package/lib/theme/common/ai.css +446 -0
  66. package/lib/theme/common/code-mirror.css +14 -0
  67. package/lib/theme/common/diff.css +177 -0
  68. package/lib/theme/common/style.css +3 -0
  69. package/lib/theme/common/top-bar.css +152 -0
  70. package/lib/tsconfig.tsbuildinfo +1 -1
  71. package/lib/types/core/builder.d.ts +2 -1
  72. package/lib/types/core/builder.d.ts.map +1 -1
  73. package/lib/types/feature/ai/ai.spec.d.ts +2 -0
  74. package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
  75. package/lib/types/feature/ai/commands.d.ts +24 -0
  76. package/lib/types/feature/ai/commands.d.ts.map +1 -0
  77. package/lib/types/feature/ai/context.d.ts +4 -0
  78. package/lib/types/feature/ai/context.d.ts.map +1 -0
  79. package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
  80. package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
  81. package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
  82. package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
  83. package/lib/types/feature/ai/index.d.ts +7 -0
  84. package/lib/types/feature/ai/index.d.ts.map +1 -0
  85. package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
  86. package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
  87. package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
  88. package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
  89. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
  90. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
  91. package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
  92. package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
  93. package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
  94. package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
  95. package/lib/types/feature/ai/types.d.ts +58 -0
  96. package/lib/types/feature/ai/types.d.ts.map +1 -0
  97. package/lib/types/feature/block-edit/handle/component.d.ts.map +1 -1
  98. package/lib/types/feature/block-edit/menu/component.d.ts.map +1 -1
  99. package/lib/types/feature/image-block/index.d.ts +2 -0
  100. package/lib/types/feature/image-block/index.d.ts.map +1 -1
  101. package/lib/types/feature/index.d.ts +7 -1
  102. package/lib/types/feature/index.d.ts.map +1 -1
  103. package/lib/types/feature/latex/inline-tooltip/component.d.ts.map +1 -1
  104. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
  105. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
  106. package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
  107. package/lib/types/feature/loader.d.ts.map +1 -1
  108. package/lib/types/feature/toolbar/component.d.ts.map +1 -1
  109. package/lib/types/feature/toolbar/config.d.ts +1 -1
  110. package/lib/types/feature/toolbar/config.d.ts.map +1 -1
  111. package/lib/types/feature/toolbar/index.d.ts +1 -0
  112. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  113. package/lib/types/feature/top-bar/component.d.ts +11 -0
  114. package/lib/types/feature/top-bar/component.d.ts.map +1 -0
  115. package/lib/types/feature/top-bar/config.d.ts +34 -0
  116. package/lib/types/feature/top-bar/config.d.ts.map +1 -0
  117. package/lib/types/feature/top-bar/index.d.ts +26 -0
  118. package/lib/types/feature/top-bar/index.d.ts.map +1 -0
  119. package/lib/types/icons/ai.d.ts +2 -0
  120. package/lib/types/icons/ai.d.ts.map +1 -0
  121. package/lib/types/icons/chevron-left.d.ts +2 -0
  122. package/lib/types/icons/chevron-left.d.ts.map +1 -0
  123. package/lib/types/icons/chevron-right.d.ts +2 -0
  124. package/lib/types/icons/chevron-right.d.ts.map +1 -0
  125. package/lib/types/icons/code-block.d.ts +2 -0
  126. package/lib/types/icons/code-block.d.ts.map +1 -0
  127. package/lib/types/icons/enter-key.d.ts +2 -0
  128. package/lib/types/icons/enter-key.d.ts.map +1 -0
  129. package/lib/types/icons/grammar-check.d.ts +2 -0
  130. package/lib/types/icons/grammar-check.d.ts.map +1 -0
  131. package/lib/types/icons/index.d.ts +12 -0
  132. package/lib/types/icons/index.d.ts.map +1 -1
  133. package/lib/types/icons/longer.d.ts +2 -0
  134. package/lib/types/icons/longer.d.ts.map +1 -0
  135. package/lib/types/icons/retry.d.ts +2 -0
  136. package/lib/types/icons/retry.d.ts.map +1 -0
  137. package/lib/types/icons/send-prompt.d.ts +2 -0
  138. package/lib/types/icons/send-prompt.d.ts.map +1 -0
  139. package/lib/types/icons/send.d.ts +2 -0
  140. package/lib/types/icons/send.d.ts.map +1 -0
  141. package/lib/types/icons/shorter.d.ts +2 -0
  142. package/lib/types/icons/shorter.d.ts.map +1 -0
  143. package/lib/types/icons/translate.d.ts +2 -0
  144. package/lib/types/icons/translate.d.ts.map +1 -0
  145. package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
  146. package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
  147. package/lib/types/llm-providers/openai/index.d.ts +15 -0
  148. package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
  149. package/lib/types/llm-providers/providers.spec.d.ts +2 -0
  150. package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
  151. package/lib/types/llm-providers/shared.d.ts +16 -0
  152. package/lib/types/llm-providers/shared.d.ts.map +1 -0
  153. package/lib/types/utils/group-builder.d.ts +1 -1
  154. package/lib/types/utils/group-builder.d.ts.map +1 -1
  155. package/lib/types/utils/keep-alive.d.ts +2 -0
  156. package/lib/types/utils/keep-alive.d.ts.map +1 -0
  157. package/package.json +34 -13
  158. package/src/core/builder.ts +39 -2
  159. package/src/feature/ai/ai.spec.ts +742 -0
  160. package/src/feature/ai/commands.ts +257 -0
  161. package/src/feature/ai/context.ts +45 -0
  162. package/src/feature/ai/diff-actions/index.ts +95 -0
  163. package/src/feature/ai/diff-actions/view.ts +237 -0
  164. package/src/feature/ai/index.ts +118 -0
  165. package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
  166. package/src/feature/ai/instruction-tooltip/index.ts +101 -0
  167. package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
  168. package/src/feature/ai/instruction-tooltip/view.ts +159 -0
  169. package/src/feature/ai/streaming-indicator.ts +183 -0
  170. package/src/feature/ai/types.ts +178 -0
  171. package/src/feature/block-edit/handle/component.tsx +3 -2
  172. package/src/feature/block-edit/menu/component.tsx +3 -2
  173. package/src/feature/block-edit/menu/config.ts +1 -1
  174. package/src/feature/image-block/index.ts +4 -0
  175. package/src/feature/index.ts +14 -2
  176. package/src/feature/latex/inline-tooltip/component.tsx +4 -2
  177. package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
  178. package/src/feature/latex/inline-tooltip/view.ts +2 -0
  179. package/src/feature/loader.ts +8 -0
  180. package/src/feature/toolbar/component.tsx +7 -5
  181. package/src/feature/toolbar/config.ts +27 -1
  182. package/src/feature/toolbar/index.ts +1 -0
  183. package/src/feature/top-bar/component.tsx +198 -0
  184. package/src/feature/top-bar/config.ts +367 -0
  185. package/src/feature/top-bar/index.ts +113 -0
  186. package/src/icons/ai.ts +14 -0
  187. package/src/icons/chevron-left.ts +15 -0
  188. package/src/icons/chevron-right.ts +15 -0
  189. package/src/icons/code-block.ts +12 -0
  190. package/src/icons/enter-key.ts +13 -0
  191. package/src/icons/grammar-check.ts +13 -0
  192. package/src/icons/index.ts +12 -0
  193. package/src/icons/longer.ts +13 -0
  194. package/src/icons/retry.ts +13 -0
  195. package/src/icons/send-prompt.ts +13 -0
  196. package/src/icons/send.ts +13 -0
  197. package/src/icons/shorter.ts +13 -0
  198. package/src/icons/translate.ts +13 -0
  199. package/src/llm-providers/anthropic/index.ts +132 -0
  200. package/src/llm-providers/openai/index.ts +109 -0
  201. package/src/llm-providers/providers.spec.ts +472 -0
  202. package/src/llm-providers/shared.ts +160 -0
  203. package/src/theme/common/ai.css +430 -0
  204. package/src/theme/common/code-mirror.css +14 -0
  205. package/src/theme/common/diff.css +196 -0
  206. package/src/theme/common/style.css +3 -0
  207. package/src/theme/common/top-bar.css +156 -0
  208. package/src/utils/group-builder.ts +1 -1
  209. package/src/utils/keep-alive.ts +3 -0
@@ -0,0 +1,249 @@
1
+ import {
2
+ aiIcon,
3
+ editIcon,
4
+ grammarCheckIcon,
5
+ longerIcon,
6
+ shorterIcon,
7
+ translateIcon,
8
+ } from '../../../icons'
9
+
10
+ /// A single AI suggestion. `prompt` is sent to the provider; `streamingLabel`
11
+ /// is the active-form text shown in the streaming indicator while AI runs.
12
+ export interface AISuggestionItem {
13
+ icon: string
14
+ label: string
15
+ streamingLabel?: string
16
+ prompt: string
17
+ }
18
+
19
+ /// A submenu definition. `title` is shown next to the back arrow when the
20
+ /// submenu is open; `searchPlaceholder` replaces the input placeholder.
21
+ export interface AISubmenuDef {
22
+ icon: string
23
+ label: string
24
+ title: string
25
+ searchPlaceholder: string
26
+ }
27
+
28
+ /// Mutates a submenu's items in place. Obtained from
29
+ /// `AISuggestionsBuilder.addSubmenu` (via the build callback) or
30
+ /// `AISuggestionsBuilder.getSubmenu(id)`.
31
+ export interface AISubmenuBuilder {
32
+ addItem: (id: string, item: AISuggestionItem) => AISubmenuBuilder
33
+ removeItem: (id: string) => AISubmenuBuilder
34
+ getItem: (id: string) => AISuggestionItem | undefined
35
+ clear: () => AISubmenuBuilder
36
+ }
37
+
38
+ interface SubmenuNode {
39
+ def: AISubmenuDef
40
+ items: Map<string, AISuggestionItem>
41
+ }
42
+
43
+ function createSubmenuBuilder(node: SubmenuNode): AISubmenuBuilder {
44
+ const builder: AISubmenuBuilder = {
45
+ addItem: (id, item) => {
46
+ node.items.set(id, item)
47
+ return builder
48
+ },
49
+ removeItem: (id) => {
50
+ node.items.delete(id)
51
+ return builder
52
+ },
53
+ getItem: (id) => node.items.get(id),
54
+ clear: () => {
55
+ node.items.clear()
56
+ return builder
57
+ },
58
+ }
59
+ return builder
60
+ }
61
+
62
+ type RootNode =
63
+ | { kind: 'item'; id: string; item: AISuggestionItem }
64
+ | { kind: 'submenu'; id: string; node: SubmenuNode }
65
+
66
+ export type ResolvedItemEntry = {
67
+ kind: 'item'
68
+ id: string
69
+ item: AISuggestionItem
70
+ }
71
+
72
+ export type ResolvedSubmenuEntry = {
73
+ kind: 'submenu'
74
+ id: string
75
+ def: AISubmenuDef
76
+ }
77
+
78
+ /// Resolved tree consumed by the tooltip view. `submenus` is keyed by
79
+ /// submenu id so the view can switch between main and submenu lists.
80
+ export interface ResolvedSuggestions {
81
+ main: Array<ResolvedItemEntry | ResolvedSubmenuEntry>
82
+ submenus: Record<
83
+ string,
84
+ { def: AISubmenuDef; items: Array<{ id: string; item: AISuggestionItem }> }
85
+ >
86
+ }
87
+
88
+ export class AISuggestionsBuilder {
89
+ #nodes: RootNode[] = []
90
+
91
+ addItem = (id: string, item: AISuggestionItem) => {
92
+ this.#removeById(id)
93
+ this.#nodes.push({ kind: 'item', id, item })
94
+ return this
95
+ }
96
+
97
+ /// Add a submenu. Populate items via the optional `build` callback,
98
+ /// or call `getSubmenu(id)` afterward. Returns `this` so calls can be
99
+ /// chained at the parent level alongside `addItem`.
100
+ addSubmenu = (
101
+ id: string,
102
+ def: AISubmenuDef,
103
+ build?: (sub: AISubmenuBuilder) => void
104
+ ) => {
105
+ this.#removeById(id)
106
+ const node: SubmenuNode = { def, items: new Map() }
107
+ if (build) build(createSubmenuBuilder(node))
108
+ this.#nodes.push({ kind: 'submenu', id, node })
109
+ return this
110
+ }
111
+
112
+ removeItem = (id: string) => {
113
+ this.#removeById(id)
114
+ return this
115
+ }
116
+
117
+ getItem = (id: string) => {
118
+ const node = this.#nodes.find((n) => n.kind === 'item' && n.id === id)
119
+ return node?.kind === 'item' ? node.item : undefined
120
+ }
121
+
122
+ /// Return a builder that mutates the submenu's items in place.
123
+ /// Multiple calls return distinct builder objects backed by the same
124
+ /// underlying node, so changes are always visible.
125
+ getSubmenu = (id: string) => {
126
+ const node = this.#nodes.find((n) => n.kind === 'submenu' && n.id === id)
127
+ return node?.kind === 'submenu'
128
+ ? createSubmenuBuilder(node.node)
129
+ : undefined
130
+ }
131
+
132
+ clear = () => {
133
+ this.#nodes = []
134
+ return this
135
+ }
136
+
137
+ build = (): ResolvedSuggestions => {
138
+ const main: ResolvedSuggestions['main'] = []
139
+ const submenus: ResolvedSuggestions['submenus'] = {}
140
+ for (const node of this.#nodes) {
141
+ if (node.kind === 'item') {
142
+ main.push({ kind: 'item', id: node.id, item: node.item })
143
+ } else {
144
+ main.push({ kind: 'submenu', id: node.id, def: node.node.def })
145
+ submenus[node.id] = {
146
+ def: node.node.def,
147
+ items: Array.from(node.node.items.entries()).map(([id, item]) => ({
148
+ id,
149
+ item,
150
+ })),
151
+ }
152
+ }
153
+ }
154
+ return { main, submenus }
155
+ }
156
+
157
+ #removeById = (id: string) => {
158
+ this.#nodes = this.#nodes.filter((n) => n.id !== id)
159
+ }
160
+ }
161
+
162
+ /// Populate a builder with the built-in defaults. Called before the
163
+ /// user's `buildAISuggestions` callback so the user can add to,
164
+ /// inspect, or remove any default.
165
+ export function applyDefaultSuggestions(builder: AISuggestionsBuilder): void {
166
+ builder
167
+ .addItem('improve', {
168
+ icon: aiIcon,
169
+ label: 'Improve writing',
170
+ streamingLabel: 'Improving writing',
171
+ prompt: 'Improve the writing while preserving the original meaning.',
172
+ })
173
+ .addItem('grammar', {
174
+ icon: grammarCheckIcon,
175
+ label: 'Fix grammar & spelling',
176
+ streamingLabel: 'Fixing grammar & spelling',
177
+ prompt:
178
+ 'Fix any grammar and spelling errors without changing the meaning.',
179
+ })
180
+ .addItem('shorter', {
181
+ icon: shorterIcon,
182
+ label: 'Make shorter',
183
+ streamingLabel: 'Making shorter',
184
+ prompt: 'Make this shorter while preserving the key information.',
185
+ })
186
+ .addItem('longer', {
187
+ icon: longerIcon,
188
+ label: 'Make longer',
189
+ streamingLabel: 'Expanding',
190
+ prompt: 'Expand this with more detail and examples.',
191
+ })
192
+
193
+ builder.addSubmenu(
194
+ 'tone',
195
+ {
196
+ icon: editIcon,
197
+ label: 'Change tone…',
198
+ title: 'Change tone',
199
+ searchPlaceholder: 'Search tones…',
200
+ },
201
+ (sub) => {
202
+ const tones: Array<[string, string]> = [
203
+ ['professional', 'Professional'],
204
+ ['casual', 'Casual'],
205
+ ['confident', 'Confident'],
206
+ ['friendly', 'Friendly'],
207
+ ['direct', 'Direct'],
208
+ ['formal', 'Formal'],
209
+ ]
210
+ for (const [id, label] of tones) {
211
+ sub.addItem(id, {
212
+ icon: editIcon,
213
+ label,
214
+ streamingLabel: 'Adjusting tone',
215
+ prompt: `Rewrite this in a ${label.toLowerCase()} tone.`,
216
+ })
217
+ }
218
+ }
219
+ )
220
+
221
+ builder.addSubmenu(
222
+ 'translate',
223
+ {
224
+ icon: translateIcon,
225
+ label: 'Translate…',
226
+ title: 'Translate',
227
+ searchPlaceholder: 'Search languages…',
228
+ },
229
+ (sub) => {
230
+ const languages: Array<[string, string, string]> = [
231
+ ['english', 'English', 'English'],
232
+ ['chinese', 'Chinese', 'Chinese (Simplified)'],
233
+ ['japanese', 'Japanese', 'Japanese'],
234
+ ['korean', 'Korean', 'Korean'],
235
+ ['spanish', 'Spanish', 'Spanish'],
236
+ ['french', 'French', 'French'],
237
+ ['german', 'German', 'German'],
238
+ ]
239
+ for (const [id, label, promptName] of languages) {
240
+ sub.addItem(id, {
241
+ icon: translateIcon,
242
+ label,
243
+ streamingLabel: `Translating to ${label}`,
244
+ prompt: `Translate this to ${promptName}.`,
245
+ })
246
+ }
247
+ }
248
+ )
249
+ }
@@ -0,0 +1,159 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+ import type { EditorState, PluginView } from '@milkdown/kit/prose/state'
3
+ import type { EditorView } from '@milkdown/kit/prose/view'
4
+
5
+ import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
6
+ import { TooltipProvider } from '@milkdown/kit/plugin/tooltip'
7
+ import { posToDOMRect } from '@milkdown/kit/prose'
8
+ import { TextSelection } from '@milkdown/kit/prose/state'
9
+ import { createApp, ref, type App } from 'vue'
10
+
11
+ import type { ResolvedSuggestions } from './suggestions'
12
+
13
+ import { runAICmd } from '../commands'
14
+ import {
15
+ AIInstructionInput,
16
+ type AIInstructionTooltipChrome,
17
+ } from './component'
18
+
19
+ export interface AIInstructionTooltipViewConfig {
20
+ placeholder: string
21
+ chrome: AIInstructionTooltipChrome
22
+ suggestions: ResolvedSuggestions
23
+ }
24
+
25
+ export class AIInstructionTooltipView implements PluginView {
26
+ #content: HTMLElement
27
+ #provider: TooltipProvider
28
+ #app: App
29
+ #placeholder
30
+ #resetSignal
31
+ #from = -1
32
+ #to = -1
33
+ /// Source of truth for "should the palette currently be showing".
34
+ /// `TooltipProvider.shouldShow` reads this so that forwarding `update()`
35
+ /// to the provider on every editor transition (needed to keep the
36
+ /// floating position in sync with layout changes) doesn't dismiss the
37
+ /// palette behind our back.
38
+ #wantsShow = false
39
+
40
+ constructor(
41
+ readonly ctx: Ctx,
42
+ view: EditorView,
43
+ config: AIInstructionTooltipViewConfig
44
+ ) {
45
+ // Wrapped in a ref so the Vue component re-renders if it ever
46
+ // changes; today there's no setter, but keeping it reactive lets a
47
+ // future API (e.g. context-sensitive placeholders) plug in cleanly.
48
+ this.#placeholder = ref(config.placeholder)
49
+ // Bumped on each `show()` so the component clears input/submenu/cursor
50
+ // state — the Vue app stays mounted across hide/show cycles, so its
51
+ // local state would otherwise persist into the next session.
52
+ this.#resetSignal = ref(0)
53
+
54
+ const content = document.createElement('div')
55
+ content.className = 'milkdown-ai-instruction'
56
+
57
+ const app = createApp(AIInstructionInput, {
58
+ placeholder: this.#placeholder,
59
+ resetSignal: this.#resetSignal,
60
+ suggestions: config.suggestions,
61
+ chrome: config.chrome,
62
+ onConfirm: this.#onConfirm,
63
+ onCancel: this.#onCancel,
64
+ })
65
+ app.mount(content)
66
+ this.#app = app
67
+ this.#content = content
68
+
69
+ this.#provider = new TooltipProvider({
70
+ content,
71
+ debounce: 0,
72
+ offset: 10,
73
+ shouldShow: () => this.#wantsShow,
74
+ floatingUIOptions: {
75
+ placement: 'bottom',
76
+ },
77
+ })
78
+ this.#provider.onHide = () => {
79
+ this.#wantsShow = false
80
+ requestAnimationFrame(() => {
81
+ try {
82
+ // Only restore focus to the editor when the palette itself
83
+ // was holding focus at hide time (Escape, Enter-to-confirm,
84
+ // selection-driven dismissal). If the user dismissed the
85
+ // palette by clicking somewhere else, focus is already on
86
+ // that target and stealing it back would frustrate keyboard
87
+ // and assistive-technology users trying to leave the editor.
88
+ const root = this.#content.getRootNode() as Document | ShadowRoot
89
+ const active = root.activeElement
90
+ if (!active || !this.#content.contains(active)) return
91
+ const v = this.ctx.get(editorViewCtx)
92
+ v.dom.focus({ preventScroll: true })
93
+ } catch {
94
+ // Editor may be destroyed
95
+ }
96
+ })
97
+ }
98
+ this.#provider.update(view)
99
+ }
100
+
101
+ #onConfirm = (instruction: string, label?: string) => {
102
+ if (!instruction.trim()) return
103
+ // Dispatch first and only dismiss the palette on success. If
104
+ // `runAICmd` rejects (no provider, an active streaming session, an
105
+ // active diff review, …) the user's typed instruction stays visible
106
+ // so they can retry once the editor is in a state that accepts it.
107
+ const commands = this.ctx.get(commandsCtx)
108
+ const accepted = commands.call(runAICmd.key, { instruction, label })
109
+ if (!accepted) return
110
+ this.#wantsShow = false
111
+ this.#provider.hide()
112
+ }
113
+
114
+ #onCancel = () => {
115
+ this.#wantsShow = false
116
+ this.#provider.hide()
117
+ }
118
+
119
+ show = (from: number, to: number) => {
120
+ this.#from = from
121
+ this.#to = to
122
+ this.#resetSignal.value++
123
+ this.#wantsShow = true
124
+ const view = this.ctx.get(editorViewCtx)
125
+ this.#provider.show(
126
+ { getBoundingClientRect: () => posToDOMRect(view, from, to) },
127
+ view
128
+ )
129
+ requestAnimationFrame(() => {
130
+ this.#content.querySelector('input')?.focus()
131
+ })
132
+ }
133
+
134
+ update = (view: EditorView, prevState?: EditorState) => {
135
+ const { selection } = view.state
136
+ // Hide whenever the anchor selection moves OR becomes a non-text
137
+ // selection (e.g. user clicks an image or table node). Otherwise the
138
+ // tooltip would stay orphaned on screen with no valid range.
139
+ const isTextSelection = selection instanceof TextSelection
140
+ const movedRange =
141
+ selection.from !== this.#from || selection.to !== this.#to
142
+ if (!isTextSelection || movedRange) {
143
+ this.#wantsShow = false
144
+ this.#provider.hide()
145
+ return
146
+ }
147
+
148
+ // Forward to the provider so floating UI re-runs its position
149
+ // calculation against the current view (anchor coordinates can shift
150
+ // with layout/document changes even when from/to stay the same).
151
+ this.#provider.update(view, prevState)
152
+ }
153
+
154
+ destroy = () => {
155
+ this.#app.unmount()
156
+ this.#provider.destroy()
157
+ this.#content.remove()
158
+ }
159
+ }
@@ -0,0 +1,183 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx'
2
+ import type { Node } from '@milkdown/kit/prose/model'
3
+
4
+ import { commandsCtx } from '@milkdown/kit/core'
5
+ import {
6
+ abortStreamingCmd,
7
+ streamingPluginKey,
8
+ } from '@milkdown/kit/plugin/streaming'
9
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
10
+ import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
11
+ import { $prose } from '@milkdown/kit/utils'
12
+
13
+ import type { AIStreamingIndicatorConfig } from './types'
14
+
15
+ import { abortAICmd, aiSessionCtx } from './commands'
16
+
17
+ const CLASS_PREFIX = 'milkdown-ai-streaming'
18
+ const SPINNER_PERIOD_MS = 800
19
+
20
+ export const DEFAULT_STREAMING_FALLBACK_LABEL = 'Generating'
21
+ export const DEFAULT_STREAMING_CANCEL_HINT = 'Esc to cancel'
22
+
23
+ const indicatorKey = new PluginKey<DecorationSet>(
24
+ 'CREPE_AI_STREAMING_INDICATOR'
25
+ )
26
+
27
+ /// One spinner per streaming session. The rotation is driven by
28
+ /// requestAnimationFrame instead of CSS keyframes so that DOM moves
29
+ /// (which happen every chunk as the indicator's position advances) do
30
+ /// not reset the animation back to 0deg.
31
+ class IndicatorWidget {
32
+ readonly dom: HTMLElement
33
+ readonly #spinner: HTMLElement
34
+ readonly #label: HTMLElement
35
+ readonly #fallbackLabel: string
36
+ readonly #start = performance.now()
37
+ #lastLabelText = ''
38
+ #rafId = 0
39
+
40
+ constructor(ctx: Ctx, fallbackLabel: string, cancelHint: string) {
41
+ this.#fallbackLabel = fallbackLabel
42
+
43
+ const dom = document.createElement('span')
44
+ dom.className = `${CLASS_PREFIX}-indicator`
45
+ dom.contentEditable = 'false'
46
+ // Treat the pill as a status live region so assistive technologies
47
+ // get notified when AI starts streaming and when the active-form
48
+ // label changes between sessions ("Improving writing…",
49
+ // "Translating to French…", etc.).
50
+ dom.setAttribute('role', 'status')
51
+ dom.setAttribute('aria-live', 'polite')
52
+
53
+ const spinner = document.createElement('span')
54
+ spinner.className = `${CLASS_PREFIX}-spinner`
55
+ // The decorative rotation has no semantic value for screen readers.
56
+ spinner.setAttribute('aria-hidden', 'true')
57
+ dom.appendChild(spinner)
58
+
59
+ const label = document.createElement('span')
60
+ label.className = `${CLASS_PREFIX}-label`
61
+ dom.appendChild(label)
62
+
63
+ const escHint = document.createElement('span')
64
+ escHint.className = `${CLASS_PREFIX}-esc`
65
+ escHint.textContent = cancelHint
66
+ dom.appendChild(escHint)
67
+
68
+ this.dom = dom
69
+ this.#spinner = spinner
70
+ this.#label = label
71
+ this.setLabel(ctx)
72
+ this.#tick()
73
+ }
74
+
75
+ setLabel(ctx: Ctx) {
76
+ const session = ctx.get(aiSessionCtx.key)
77
+ const text = `${session.label || this.#fallbackLabel}…`
78
+ if (text === this.#lastLabelText) return
79
+ this.#lastLabelText = text
80
+ this.#label.textContent = text
81
+ }
82
+
83
+ destroy() {
84
+ if (this.#rafId) cancelAnimationFrame(this.#rafId)
85
+ this.#rafId = 0
86
+ }
87
+
88
+ #tick = () => {
89
+ const elapsed = performance.now() - this.#start
90
+ const angle = (elapsed / SPINNER_PERIOD_MS) * 360
91
+ this.#spinner.style.transform = `rotate(${angle}deg)`
92
+ this.#rafId = requestAnimationFrame(this.#tick)
93
+ }
94
+ }
95
+
96
+ interface StreamingIndicatorPluginOptions {
97
+ config?: AIStreamingIndicatorConfig
98
+ }
99
+
100
+ export function streamingIndicatorPlugin(
101
+ options: StreamingIndicatorPluginOptions = {}
102
+ ) {
103
+ const { config } = options
104
+ const fallbackLabel =
105
+ config?.fallbackLabel ?? DEFAULT_STREAMING_FALLBACK_LABEL
106
+ const cancelHint = config?.cancelHint ?? DEFAULT_STREAMING_CANCEL_HINT
107
+
108
+ return $prose((ctx) => {
109
+ let widget: IndicatorWidget | null = null
110
+
111
+ function ensureWidget(): IndicatorWidget {
112
+ if (!widget) widget = new IndicatorWidget(ctx, fallbackLabel, cancelHint)
113
+ else widget.setLabel(ctx)
114
+ return widget
115
+ }
116
+
117
+ function dropWidget(): void {
118
+ if (widget) {
119
+ widget.destroy()
120
+ widget = null
121
+ }
122
+ }
123
+
124
+ function buildSet(doc: Node, insertEndPos: number): DecorationSet {
125
+ const pos = Math.min(insertEndPos, doc.content.size)
126
+ const decoration = Decoration.widget(pos, ensureWidget().dom, {
127
+ side: 1,
128
+ key: 'ai-streaming-indicator',
129
+ })
130
+ return DecorationSet.create(doc, [decoration])
131
+ }
132
+
133
+ return new Plugin<DecorationSet>({
134
+ key: indicatorKey,
135
+ state: {
136
+ init: () => DecorationSet.empty,
137
+ apply(tr, decorations, _oldState, newState) {
138
+ const streaming = streamingPluginKey.getState(newState)
139
+ if (!streaming?.active || streaming.insertEndPos == null) {
140
+ dropWidget()
141
+ return DecorationSet.empty
142
+ }
143
+ if (tr.getMeta(streamingPluginKey) || tr.docChanged) {
144
+ return buildSet(newState.doc, streaming.insertEndPos)
145
+ }
146
+ return decorations.map(tr.mapping, tr.doc)
147
+ },
148
+ },
149
+ view() {
150
+ return {
151
+ destroy() {
152
+ dropWidget()
153
+ },
154
+ }
155
+ },
156
+ props: {
157
+ decorations(state) {
158
+ return indicatorKey.getState(state) ?? DecorationSet.empty
159
+ },
160
+ handleKeyDown(view, event) {
161
+ if (event.key !== 'Escape') return false
162
+ const commands = ctx.get(commandsCtx)
163
+ // AI-driven session: route through abortAICmd so the AI
164
+ // session state (abortController, label) cleans up too.
165
+ if (ctx.get(aiSessionCtx.key).abortController) {
166
+ event.preventDefault()
167
+ commands.call(abortAICmd.key, { keep: true })
168
+ return true
169
+ }
170
+ // Manual streaming session (no AI session in flight) — abort
171
+ // the streaming plugin directly so the "Esc to cancel" hint
172
+ // shown in the pill isn't misleading.
173
+ if (streamingPluginKey.getState(view.state)?.active) {
174
+ event.preventDefault()
175
+ commands.call(abortStreamingCmd.key, { keep: true })
176
+ return true
177
+ }
178
+ return false
179
+ },
180
+ },
181
+ })
182
+ })
183
+ }