@actuate-media/cms-admin 0.11.0 → 0.12.1
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 -21
- package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +200 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
- package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
- package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +155 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +45 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +90 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/fields/ComponentBlockField.d.ts +25 -0
- package/dist/fields/ComponentBlockField.d.ts.map +1 -0
- package/dist/fields/ComponentBlockField.js +74 -0
- package/dist/fields/ComponentBlockField.js.map +1 -0
- package/dist/fields/FieldRenderer.d.ts +3 -0
- package/dist/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/fields/FieldRenderer.js +3 -1
- package/dist/fields/FieldRenderer.js.map +1 -1
- package/dist/fields/PropInput.d.ts +14 -0
- package/dist/fields/PropInput.d.ts.map +1 -0
- package/dist/fields/PropInput.js +163 -0
- package/dist/fields/PropInput.js.map +1 -0
- package/dist/fields/component-block-helpers.d.ts +96 -0
- package/dist/fields/component-block-helpers.d.ts.map +1 -0
- package/dist/fields/component-block-helpers.js +323 -0
- package/dist/fields/component-block-helpers.js.map +1 -0
- package/dist/fields/index.d.ts +4 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +68 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +85 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +20 -4
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +213 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +195 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +242 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/fields/ComponentBlockField.tsx +179 -0
- package/src/fields/FieldRenderer.tsx +8 -0
- package/src/fields/PropInput.tsx +552 -0
- package/src/fields/component-block-helpers.ts +341 -0
- package/src/fields/index.ts +4 -0
- package/src/index.ts +27 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +146 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseAiSlashCommand,
|
|
4
|
+
isAiSlashCommandInput,
|
|
5
|
+
orderedAiActions,
|
|
6
|
+
} from '../../lib/slash-command.js'
|
|
7
|
+
|
|
8
|
+
describe('parseAiSlashCommand', () => {
|
|
9
|
+
it('parses /ai rewrite <text>', () => {
|
|
10
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
11
|
+
action: 'rewrite',
|
|
12
|
+
text: 'hello world',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses every canonical action', () => {
|
|
17
|
+
expect(parseAiSlashCommand('/ai expand foo')).toEqual({ action: 'expand', text: 'foo' })
|
|
18
|
+
expect(parseAiSlashCommand('/ai compress bar')).toEqual({ action: 'compress', text: 'bar' })
|
|
19
|
+
expect(parseAiSlashCommand('/ai proofread baz')).toEqual({ action: 'proofread', text: 'baz' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is case-insensitive on both prefix and action', () => {
|
|
23
|
+
expect(parseAiSlashCommand('/AI Rewrite ALL CAPS')).toEqual({
|
|
24
|
+
action: 'rewrite',
|
|
25
|
+
text: 'ALL CAPS',
|
|
26
|
+
})
|
|
27
|
+
expect(parseAiSlashCommand('/Ai PROOFREAD x')).toEqual({ action: 'proofread', text: 'x' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('tolerates leading whitespace', () => {
|
|
31
|
+
expect(parseAiSlashCommand(' /ai rewrite x')).toEqual({ action: 'rewrite', text: 'x' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns empty text when nothing follows the action', () => {
|
|
35
|
+
expect(parseAiSlashCommand('/ai rewrite')).toEqual({ action: 'rewrite', text: '' })
|
|
36
|
+
expect(parseAiSlashCommand('/ai rewrite ')).toEqual({ action: 'rewrite', text: '' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('trims surrounding whitespace from the payload', () => {
|
|
40
|
+
expect(parseAiSlashCommand('/ai rewrite hello ')).toEqual({
|
|
41
|
+
action: 'rewrite',
|
|
42
|
+
text: 'hello',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('preserves interior whitespace in the payload', () => {
|
|
47
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
48
|
+
action: 'rewrite',
|
|
49
|
+
text: 'hello world',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null for unknown actions', () => {
|
|
54
|
+
expect(parseAiSlashCommand('/ai summarize foo')).toBeNull()
|
|
55
|
+
expect(parseAiSlashCommand('/ai delete foo')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns null for non-AI slash commands', () => {
|
|
59
|
+
expect(parseAiSlashCommand('/help')).toBeNull()
|
|
60
|
+
expect(parseAiSlashCommand('/admin x')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns null for plain input without a slash', () => {
|
|
64
|
+
expect(parseAiSlashCommand('ai rewrite hello')).toBeNull()
|
|
65
|
+
expect(parseAiSlashCommand('hello world')).toBeNull()
|
|
66
|
+
expect(parseAiSlashCommand('')).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns null when /ai is the entire input', () => {
|
|
70
|
+
expect(parseAiSlashCommand('/ai')).toBeNull()
|
|
71
|
+
expect(parseAiSlashCommand('/ai ')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null for non-string inputs', () => {
|
|
75
|
+
// @ts-expect-error — runtime guard
|
|
76
|
+
expect(parseAiSlashCommand(null)).toBeNull()
|
|
77
|
+
// @ts-expect-error — runtime guard
|
|
78
|
+
expect(parseAiSlashCommand(undefined)).toBeNull()
|
|
79
|
+
// @ts-expect-error — runtime guard
|
|
80
|
+
expect(parseAiSlashCommand(42)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('isAiSlashCommandInput', () => {
|
|
85
|
+
it('returns true while the user is mid-typing the prefix', () => {
|
|
86
|
+
expect(isAiSlashCommandInput('/')).toBe(true)
|
|
87
|
+
expect(isAiSlashCommandInput('/a')).toBe(true)
|
|
88
|
+
expect(isAiSlashCommandInput('/ai')).toBe(true)
|
|
89
|
+
expect(isAiSlashCommandInput('/ai ')).toBe(true)
|
|
90
|
+
expect(isAiSlashCommandInput('/ai re')).toBe(true)
|
|
91
|
+
expect(isAiSlashCommandInput('/ai rewrite something')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns true when the user typed AI with capitals', () => {
|
|
95
|
+
expect(isAiSlashCommandInput('/AI')).toBe(true)
|
|
96
|
+
expect(isAiSlashCommandInput('/Ai rew')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns false for ordinary text', () => {
|
|
100
|
+
expect(isAiSlashCommandInput('')).toBe(false)
|
|
101
|
+
expect(isAiSlashCommandInput('hello')).toBe(false)
|
|
102
|
+
expect(isAiSlashCommandInput('ai rewrite')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns false for non-AI slash inputs', () => {
|
|
106
|
+
expect(isAiSlashCommandInput('/help')).toBe(false)
|
|
107
|
+
expect(isAiSlashCommandInput('/bots')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('orderedAiActions', () => {
|
|
112
|
+
it('returns the canonical order when nothing is parsed', () => {
|
|
113
|
+
expect(orderedAiActions('')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
114
|
+
expect(orderedAiActions('/ai')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('hoists the matched action to the front', () => {
|
|
118
|
+
expect(orderedAiActions('/ai compress foo')).toEqual([
|
|
119
|
+
'compress',
|
|
120
|
+
'rewrite',
|
|
121
|
+
'expand',
|
|
122
|
+
'proofread',
|
|
123
|
+
])
|
|
124
|
+
expect(orderedAiActions('/ai proofread bar')).toEqual([
|
|
125
|
+
'proofread',
|
|
126
|
+
'rewrite',
|
|
127
|
+
'expand',
|
|
128
|
+
'compress',
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns canonical order for an unknown action', () => {
|
|
133
|
+
expect(orderedAiActions('/ai summarize foo')).toEqual([
|
|
134
|
+
'rewrite',
|
|
135
|
+
'expand',
|
|
136
|
+
'compress',
|
|
137
|
+
'proofread',
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('contains every canonical action exactly once', () => {
|
|
142
|
+
const result = orderedAiActions('/ai rewrite x')
|
|
143
|
+
expect(result.length).toBe(4)
|
|
144
|
+
expect(new Set(result).size).toBe(4)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
|
+
import { BubbleMenu, type Editor } from '@tiptap/react'
|
|
5
|
+
import { Loader2, Maximize2, Minimize2, Sparkles, SpellCheck, Wand2 } from 'lucide-react'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
import {
|
|
8
|
+
COAUTHOR_ACTIONS,
|
|
9
|
+
coauthorActionLabel,
|
|
10
|
+
coauthorText,
|
|
11
|
+
type CoauthorAction,
|
|
12
|
+
type ProofreadChange,
|
|
13
|
+
} from '../lib/coauthor-client.js'
|
|
14
|
+
import { CoauthorResultPopover } from './CoauthorResultPopover.js'
|
|
15
|
+
|
|
16
|
+
export interface AICoauthorBubbleMenuProps {
|
|
17
|
+
editor: Editor | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ACTION_ICONS: Record<CoauthorAction, typeof Sparkles> = {
|
|
21
|
+
rewrite: Wand2,
|
|
22
|
+
expand: Maximize2,
|
|
23
|
+
compress: Minimize2,
|
|
24
|
+
proofread: SpellCheck,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ResultState {
|
|
28
|
+
open: boolean
|
|
29
|
+
loading: boolean
|
|
30
|
+
action: CoauthorAction
|
|
31
|
+
before: string
|
|
32
|
+
after: string
|
|
33
|
+
error?: string
|
|
34
|
+
changes?: ProofreadChange[]
|
|
35
|
+
from: number
|
|
36
|
+
to: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const INITIAL: ResultState = {
|
|
40
|
+
open: false,
|
|
41
|
+
loading: false,
|
|
42
|
+
action: 'rewrite',
|
|
43
|
+
before: '',
|
|
44
|
+
after: '',
|
|
45
|
+
from: 0,
|
|
46
|
+
to: 0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Floating menu that appears above the current text selection inside the
|
|
51
|
+
* TipTap editor. Renders four AI co-author actions
|
|
52
|
+
* (rewrite / expand / compress / proofread) and hands the selected text to
|
|
53
|
+
* `/ai/coauthor`. When the server returns, opens the shared
|
|
54
|
+
* `CoauthorResultPopover` with a before/after diff and Apply / Copy /
|
|
55
|
+
* Discard actions.
|
|
56
|
+
*
|
|
57
|
+
* The popover, not the bubble itself, holds the result — by design — so
|
|
58
|
+
* the user can scroll the editor, see the source of truth, and explicitly
|
|
59
|
+
* choose what to do with the AI output.
|
|
60
|
+
*/
|
|
61
|
+
export function AICoauthorBubbleMenu({ editor }: AICoauthorBubbleMenuProps) {
|
|
62
|
+
const [state, setState] = useState<ResultState>(INITIAL)
|
|
63
|
+
const [pendingAction, setPendingAction] = useState<CoauthorAction | null>(null)
|
|
64
|
+
|
|
65
|
+
const runAction = useCallback(
|
|
66
|
+
async (action: CoauthorAction) => {
|
|
67
|
+
if (!editor) return
|
|
68
|
+
const { from, to, empty } = editor.state.selection
|
|
69
|
+
if (empty) return
|
|
70
|
+
const before = editor.state.doc.textBetween(from, to, '\n', '\n')
|
|
71
|
+
if (!before.trim()) {
|
|
72
|
+
toast.error('Select text to use the AI co-author.')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setPendingAction(action)
|
|
77
|
+
setState({
|
|
78
|
+
open: true,
|
|
79
|
+
loading: true,
|
|
80
|
+
action,
|
|
81
|
+
before,
|
|
82
|
+
after: '',
|
|
83
|
+
from,
|
|
84
|
+
to,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const outcome = await coauthorText(action, before)
|
|
88
|
+
if (!outcome.ok) {
|
|
89
|
+
setState((prev) => ({ ...prev, loading: false, error: outcome.error }))
|
|
90
|
+
toast.error(outcome.error)
|
|
91
|
+
setPendingAction(null)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setState((prev) => ({
|
|
96
|
+
...prev,
|
|
97
|
+
loading: false,
|
|
98
|
+
after: outcome.result.text,
|
|
99
|
+
changes: outcome.result.changes,
|
|
100
|
+
error: undefined,
|
|
101
|
+
}))
|
|
102
|
+
setPendingAction(null)
|
|
103
|
+
},
|
|
104
|
+
[editor],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const handleApply = useCallback(() => {
|
|
108
|
+
if (!editor) return
|
|
109
|
+
editor.chain().focus().insertContentAt({ from: state.from, to: state.to }, state.after).run()
|
|
110
|
+
toast.success(`${coauthorActionLabel(state.action)} applied.`)
|
|
111
|
+
setState(INITIAL)
|
|
112
|
+
}, [editor, state.action, state.from, state.to, state.after])
|
|
113
|
+
|
|
114
|
+
const handleCopy = useCallback(async () => {
|
|
115
|
+
try {
|
|
116
|
+
await navigator.clipboard.writeText(state.after)
|
|
117
|
+
toast.success('Copied to clipboard.')
|
|
118
|
+
} catch {
|
|
119
|
+
toast.error('Clipboard unavailable — copy the text manually.')
|
|
120
|
+
}
|
|
121
|
+
}, [state.after])
|
|
122
|
+
|
|
123
|
+
const handleDiscard = useCallback(() => {
|
|
124
|
+
setState(INITIAL)
|
|
125
|
+
setPendingAction(null)
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
if (!editor) return null
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<BubbleMenu
|
|
133
|
+
editor={editor}
|
|
134
|
+
shouldShow={({ editor: ed, from, to }) => {
|
|
135
|
+
if (state.open) return false
|
|
136
|
+
if (from === to) return false
|
|
137
|
+
if (!ed.isEditable) return false
|
|
138
|
+
return true
|
|
139
|
+
}}
|
|
140
|
+
tippyOptions={{ duration: 100, placement: 'top' }}
|
|
141
|
+
className="flex items-center gap-0.5 rounded-md border border-gray-200 bg-white p-1 shadow-md"
|
|
142
|
+
>
|
|
143
|
+
<span className="flex items-center gap-1 px-1.5 text-xs font-medium text-gray-500">
|
|
144
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
145
|
+
AI
|
|
146
|
+
</span>
|
|
147
|
+
<span className="mx-0.5 h-4 w-px bg-gray-200" />
|
|
148
|
+
{COAUTHOR_ACTIONS.map((id) => {
|
|
149
|
+
const Icon = ACTION_ICONS[id]
|
|
150
|
+
const isPending = pendingAction === id
|
|
151
|
+
const anyPending = pendingAction !== null
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
key={id}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => runAction(id)}
|
|
157
|
+
disabled={anyPending}
|
|
158
|
+
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-700 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
159
|
+
title={`AI ${coauthorActionLabel(id)}`}
|
|
160
|
+
>
|
|
161
|
+
{isPending ? (
|
|
162
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
163
|
+
) : (
|
|
164
|
+
<Icon className="h-3 w-3" aria-hidden />
|
|
165
|
+
)}
|
|
166
|
+
{coauthorActionLabel(id)}
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
})}
|
|
170
|
+
</BubbleMenu>
|
|
171
|
+
|
|
172
|
+
<CoauthorResultPopover
|
|
173
|
+
open={state.open}
|
|
174
|
+
loading={state.loading}
|
|
175
|
+
action={state.action}
|
|
176
|
+
before={state.before}
|
|
177
|
+
after={state.after}
|
|
178
|
+
error={state.error}
|
|
179
|
+
changes={state.changes}
|
|
180
|
+
applyLabel="Replace selection"
|
|
181
|
+
onApply={handleApply}
|
|
182
|
+
onCopy={handleCopy}
|
|
183
|
+
onDiscard={handleDiscard}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { Check, Copy, Loader2, Sparkles, X } from 'lucide-react'
|
|
5
|
+
import type { CoauthorAction, ProofreadChange } from '../lib/coauthor-client.js'
|
|
6
|
+
import { coauthorActionLabel } from '../lib/coauthor-client.js'
|
|
7
|
+
|
|
8
|
+
export interface CoauthorResultPopoverProps {
|
|
9
|
+
open: boolean
|
|
10
|
+
loading: boolean
|
|
11
|
+
action: CoauthorAction
|
|
12
|
+
before: string
|
|
13
|
+
after: string
|
|
14
|
+
error?: string
|
|
15
|
+
changes?: ProofreadChange[]
|
|
16
|
+
/** Label for the apply target, e.g. "Apply to editor" or "Replace selection". */
|
|
17
|
+
applyLabel?: string
|
|
18
|
+
onApply: () => void
|
|
19
|
+
onCopy: () => void
|
|
20
|
+
onDiscard: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Universal AI-result panel rendered by both the TipTap bubble menu and the
|
|
25
|
+
* Cmd+K slash-command palette. Shows a before/after diff plus three actions:
|
|
26
|
+
*
|
|
27
|
+
* - Apply (write the result back to the active target)
|
|
28
|
+
* - Copy (copy the result to the clipboard)
|
|
29
|
+
* - Discard (close without changing anything)
|
|
30
|
+
*
|
|
31
|
+
* The popover takes the same `{ open, loading, action, before, after, error }`
|
|
32
|
+
* state shape regardless of caller so the UX stays identical across surfaces.
|
|
33
|
+
*/
|
|
34
|
+
export function CoauthorResultPopover({
|
|
35
|
+
open,
|
|
36
|
+
loading,
|
|
37
|
+
action,
|
|
38
|
+
before,
|
|
39
|
+
after,
|
|
40
|
+
error,
|
|
41
|
+
changes,
|
|
42
|
+
applyLabel,
|
|
43
|
+
onApply,
|
|
44
|
+
onCopy,
|
|
45
|
+
onDiscard,
|
|
46
|
+
}: CoauthorResultPopoverProps) {
|
|
47
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return
|
|
51
|
+
const onKey = (e: KeyboardEvent) => {
|
|
52
|
+
if (e.key === 'Escape') {
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
onDiscard()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
document.addEventListener('keydown', onKey)
|
|
58
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
59
|
+
}, [open, onDiscard])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) return
|
|
63
|
+
dialogRef.current?.focus()
|
|
64
|
+
}, [open])
|
|
65
|
+
|
|
66
|
+
if (!open) return null
|
|
67
|
+
|
|
68
|
+
const applyDisabled = loading || !!error || after.length === 0
|
|
69
|
+
const copyDisabled = loading || !!error || after.length === 0
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
|
74
|
+
role="presentation"
|
|
75
|
+
onClick={onDiscard}
|
|
76
|
+
>
|
|
77
|
+
<div
|
|
78
|
+
ref={dialogRef}
|
|
79
|
+
role="dialog"
|
|
80
|
+
aria-modal="true"
|
|
81
|
+
aria-label={`AI ${coauthorActionLabel(action)} result`}
|
|
82
|
+
tabIndex={-1}
|
|
83
|
+
onClick={(e) => e.stopPropagation()}
|
|
84
|
+
className="w-full max-w-2xl rounded-lg bg-white shadow-2xl outline-none"
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
87
|
+
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
|
88
|
+
<Sparkles className="h-4 w-4 text-blue-600" aria-hidden />
|
|
89
|
+
<span>AI {coauthorActionLabel(action)}</span>
|
|
90
|
+
{loading && (
|
|
91
|
+
<Loader2 className="ml-1 h-4 w-4 animate-spin text-gray-400" aria-label="Loading" />
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={onDiscard}
|
|
97
|
+
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
98
|
+
aria-label="Close"
|
|
99
|
+
>
|
|
100
|
+
<X className="h-4 w-4" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="grid grid-cols-2 gap-4 p-4">
|
|
105
|
+
<section aria-labelledby="coauthor-before">
|
|
106
|
+
<h3
|
|
107
|
+
id="coauthor-before"
|
|
108
|
+
className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase"
|
|
109
|
+
>
|
|
110
|
+
Before
|
|
111
|
+
</h3>
|
|
112
|
+
<pre className="max-h-72 overflow-auto rounded border border-gray-200 bg-gray-50 p-3 text-sm whitespace-pre-wrap text-gray-700">
|
|
113
|
+
{before}
|
|
114
|
+
</pre>
|
|
115
|
+
</section>
|
|
116
|
+
<section aria-labelledby="coauthor-after">
|
|
117
|
+
<h3
|
|
118
|
+
id="coauthor-after"
|
|
119
|
+
className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase"
|
|
120
|
+
>
|
|
121
|
+
After
|
|
122
|
+
</h3>
|
|
123
|
+
{loading ? (
|
|
124
|
+
<div
|
|
125
|
+
className="flex h-72 items-center justify-center rounded border border-dashed border-gray-200 bg-gray-50 text-sm text-gray-400"
|
|
126
|
+
role="status"
|
|
127
|
+
>
|
|
128
|
+
Thinking…
|
|
129
|
+
</div>
|
|
130
|
+
) : error ? (
|
|
131
|
+
<div className="flex h-72 items-center justify-center rounded border border-dashed border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
|
132
|
+
{error}
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<pre className="max-h-72 overflow-auto rounded border border-blue-200 bg-blue-50 p-3 text-sm whitespace-pre-wrap text-gray-900">
|
|
136
|
+
{after}
|
|
137
|
+
</pre>
|
|
138
|
+
)}
|
|
139
|
+
</section>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{changes && changes.length > 0 && !loading && !error && (
|
|
143
|
+
<div className="border-t border-gray-200 px-4 py-3">
|
|
144
|
+
<h3 className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase">
|
|
145
|
+
Changes ({changes.length})
|
|
146
|
+
</h3>
|
|
147
|
+
<ul className="max-h-32 space-y-1 overflow-auto text-xs">
|
|
148
|
+
{changes.map((change, i) => (
|
|
149
|
+
<li key={i} className="flex items-start gap-2 rounded bg-gray-50 px-2 py-1">
|
|
150
|
+
<span className="text-red-600 line-through">{change.original}</span>
|
|
151
|
+
<span className="text-gray-400">→</span>
|
|
152
|
+
<span className="text-green-700">{change.corrected}</span>
|
|
153
|
+
{change.reason && (
|
|
154
|
+
<span className="ml-auto text-gray-500 italic">{change.reason}</span>
|
|
155
|
+
)}
|
|
156
|
+
</li>
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
<div className="flex items-center justify-end gap-2 border-t border-gray-200 bg-gray-50 px-4 py-3">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={onDiscard}
|
|
166
|
+
className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
|
167
|
+
>
|
|
168
|
+
<X className="mr-1.5 inline h-3.5 w-3.5" aria-hidden />
|
|
169
|
+
Discard
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={onCopy}
|
|
174
|
+
disabled={copyDisabled}
|
|
175
|
+
className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
176
|
+
>
|
|
177
|
+
<Copy className="mr-1.5 inline h-3.5 w-3.5" aria-hidden />
|
|
178
|
+
Copy
|
|
179
|
+
</button>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={onApply}
|
|
183
|
+
disabled={applyDisabled}
|
|
184
|
+
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
185
|
+
>
|
|
186
|
+
<Check className="mr-1.5 inline h-3.5 w-3.5" aria-hidden />
|
|
187
|
+
{applyLabel ?? 'Apply'}
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|