Skip to content

Commit 495ae71

Browse files
authored
Merge pull request #501 from Harbour-Enterprises/chore/spike/new-loader-mark
feat(ai): enhance loading states for writer and re-writer
2 parents cda65ea + 836667c commit 495ae71

15 files changed

Lines changed: 311 additions & 56 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
@import './prosemirror.css';
22
@import './toolbar.css';
3-
@import './toolbar-custom.css';
3+
@import './ai.css';

packages/super-editor/src/assets/styles/elements/toolbar-custom.css renamed to packages/super-editor/src/assets/styles/elements/ai.css

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
filter: brightness(1.3);
3636
}
3737

38+
39+
/* AI text appear animation */
3840
@keyframes aiTextAppear {
3941
from {
4042
opacity: 0;
@@ -54,4 +56,30 @@
5456
will-change: opacity, transform;
5557
/* Ensure each mark is treated as a separate animation context */
5658
contain: content;
57-
}
59+
}
60+
61+
.sd-ai-loader {
62+
display: flex;
63+
justify-content: flex-start;
64+
}
65+
66+
.sd-ai-loader > img {
67+
width: fit-content;
68+
height: 40px;
69+
}
70+
71+
@keyframes ai-pulse {
72+
0% {
73+
background-color: rgba(99, 102, 241, 0.1);
74+
}
75+
50% {
76+
background-color: rgba(99, 102, 241, 0.375);
77+
}
78+
100% {
79+
background-color: rgba(99, 102, 241, 0.1);
80+
}
81+
}
82+
83+
.sd-ai-highlight-pulse {
84+
animation: ai-pulse 1.5s ease-in-out infinite;
85+
}

packages/super-editor/src/components/toolbar/AIWriter.vue

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
<script setup>
22
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue';
33
import { writeStreaming, rewriteStreaming } from './ai-helpers';
4+
import { TextSelection } from 'prosemirror-state';
45
import edit from '@harbour-enterprises/common/icons/edit-regular.svg?raw';
56
import paperPlane from '@harbour-enterprises/common/icons/paper-plane-regular.svg?raw';
6-
import timesCircle from '@harbour-enterprises/common/icons/times-circle-regular.svg?raw';
7-
import sun from '@harbour-enterprises/common/icons/sun-regular.svg?raw';
87
98
const props = defineProps({
109
selectedText: {
@@ -37,6 +36,8 @@ const props = defineProps({
3736
},
3837
});
3938
39+
const emits = defineEmits(['ai-highlight']);
40+
4041
// Store the selection state
4142
const selectionState = ref(null);
4243
@@ -45,6 +46,10 @@ const aiWriterRef = ref(null);
4546
4647
const handleClickOutside = (event) => {
4748
if (aiWriterRef.value && !aiWriterRef.value.contains(event.target)) {
49+
// Only emit 'remove' if we're not in a loading state
50+
if (!isLoading.value) {
51+
emitAiHighlight('remove');
52+
}
4853
props.handleClose();
4954
}
5055
};
@@ -53,16 +58,25 @@ const handleClickOutside = (event) => {
5358
const editableRef = ref(null);
5459
5560
// Helper function to emit AI highlight events
61+
// We need to emit through the superToolbar if it exists, otherwise emit through the emits
62+
// Hoping we can simplify this logic in the future if we combine SE and SD
5663
const emitAiHighlight = (type, data = null) => {
5764
if (props.superToolbar) {
5865
props.superToolbar.emit('ai-highlight', { type, data });
66+
} else {
67+
emits('ai-highlight', { type, data });
5968
}
6069
};
6170
6271
// Helper functions
6372
const saveSelection = () => {
6473
if (props.selectedText) {
65-
selectionState.value = props.editor.state.selection;
74+
// Store the complete selection state
75+
selectionState.value = {
76+
...props.editor.state.selection,
77+
from: props.editor.state.selection.from,
78+
to: props.editor.state.selection.to
79+
};
6680
// Store the selection in the editor's state
6781
props.editor.commands.setMeta('storedSelection', selectionState.value);
6882
@@ -97,9 +111,10 @@ onMounted(() => {
97111
});
98112
99113
onUnmounted(() => {
100-
// emit the ai highlight remove event through the toolbar
101-
emitAiHighlight('remove');
102-
114+
// Only emit 'remove' if we're not in a loading state
115+
if (!isLoading.value) {
116+
emitAiHighlight('remove');
117+
}
103118
// Remove all event listeners
104119
removeEventListeners();
105120
});
@@ -123,6 +138,8 @@ const placeholderText = computed(() =>
123138
const isLoading = ref(false);
124139
const isError = ref('');
125140
const promptText = ref('');
141+
const textProcessingStarted = ref(false);
142+
const previousText = ref('');
126143
127144
// Computed property to check if editor is in suggesting mode
128145
const isInSuggestingMode = computed(() => {
@@ -144,10 +161,39 @@ const getDocumentXml = () => {
144161
// Handler for processing text chunks from the stream
145162
const handleTextChunk = (text) => {
146163
try {
147-
// If this is the first chunk and we're rewriting, remove the selected text
164+
// Remove the loader node when we start receiving text
165+
props.editor.commands.removeAiNode('aiLoaderNode');
166+
167+
// If this is the first chunk and we're rewriting, handle the selection
148168
if (props.selectedText && !textProcessingStarted.value) {
169+
emitAiHighlight('remove');
170+
171+
// Clear the pulsing animation when we start inserting text
172+
props.editor.commands.clearAiHighlightStyle();
173+
174+
// Check if we have a valid stored selection
175+
if (selectionState.value) {
176+
// Apply the stored selection using the TextSelection API
177+
const { state } = props.editor;
178+
const { from, to } = selectionState.value;
179+
180+
// Create a transaction to set the selection
181+
const tr = state.tr.setSelection(
182+
TextSelection.create(state.doc, from, to)
183+
);
184+
185+
// Dispatch the transaction to update the editor state
186+
props.editor.view.dispatch(tr);
187+
} else {
188+
console.warn('[AIWriter] No stored selection to restore');
189+
}
190+
191+
// Now delete the selection
149192
props.editor.commands.deleteSelection();
193+
194+
// Mark as processed
150195
textProcessingStarted.value = true;
196+
151197
}
152198
153199
// If the text is null, undefined or empty, don't process it
@@ -188,22 +234,33 @@ const handleDone = () => {
188234
// We need to wait for the animation to finish before removing the mark
189235
setTimeout(() => {
190236
props.editor.commands.removeAiMark('aiAnimationMark');
237+
// Remove the highlight when we're done
238+
emitAiHighlight('remove');
191239
}, 1000);
192240
};
193241
194-
// Track text processing state
195-
const textProcessingStarted = ref(false);
196-
const previousText = ref('');
197-
198242
// Refactored handleSubmit function
199243
const handleSubmit = async () => {
200244
// Reset state
201-
isLoading.value = true;
202245
isError.value = '';
203246
textProcessingStarted.value = false;
204247
previousText.value = '';
248+
isLoading.value = true; // Set loading to true before starting the operation
205249
206250
try {
251+
// Close the AI Writer immediately and transition to loading states
252+
props.handleClose();
253+
254+
// If there is selected text, emit the update event to start pulsing animation
255+
if (props.selectedText) {
256+
emitAiHighlight('update');
257+
} else {
258+
// Insert the loader node at the current cursor position
259+
props.editor.commands.insertContent({
260+
type: 'aiLoaderNode',
261+
});
262+
}
263+
207264
// Enable track changes if in suggesting mode
208265
if (isInSuggestingMode.value) {
209266
props.editor.commands.enableTrackChanges();
@@ -233,18 +290,19 @@ const handleSubmit = async () => {
233290
await writeStreaming(promptText.value, options, handleTextChunk, handleDone);
234291
}
235292
236-
// If all is good, close the AI Writer
237-
props.handleClose();
238293
} catch (error) {
239294
console.error('AI generation error:', error);
240295
isError.value = error.message || 'An error occurred';
241296
} finally {
242-
promptText.value = ''; // Clear the input after submission
297+
// Clear the input after submission
298+
promptText.value = '';
243299
// Only disable track changes if we enabled it (in suggesting mode)
244300
if (isInSuggestingMode.value) {
245301
props.editor.commands.disableTrackChanges();
246302
}
247303
isLoading.value = false;
304+
// Ensure textProcessingStarted is reset for next operation
305+
textProcessingStarted.value = false;
248306
}
249307
};
250308
@@ -283,11 +341,7 @@ const handleInput = (event) => {
283341
></textarea>
284342
</div>
285343
<div class="ai-loader">
286-
<span v-if="isLoading" class="ai-textarea-icon loading spinner-wrapper">
287-
<span v-html="sun"></span>
288-
</span>
289-
<span v-else-if="isError" class="ai-textarea-icon error" v-html="timesCircle" />
290-
<span v-else-if="promptText" class="ai-textarea-icon ai-submit-button" @click.stop="handleSubmit" v-html="paperPlane" />
344+
<span v-if="promptText" class="ai-textarea-icon ai-submit-button" @click.stop="handleSubmit" v-html="paperPlane" />
291345
</div>
292346
</div>
293347
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const AiMarkName = 'aiMark';
22
export const AiAnimationMarkName = 'aiAnimationMark';
3+
export const AiLoaderNodeName = 'aiLoaderNode';

packages/super-editor/src/extensions/ai/ai-marks.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Mark, Attribute } from '@core/index.js';
1+
import { Mark, Attribute, Node } from '@core/index.js';
22
import { AiMarkName, AiAnimationMarkName } from './ai-constants.js';
33

44
export const AiMark = Mark.create({
@@ -10,7 +10,7 @@ export const AiMark = Mark.create({
1010

1111
addOptions() {
1212
return {
13-
htmlAttributes: { class: 'sd-editor-ai' },
13+
htmlAttributes: { class: 'sd-ai-highlight' },
1414
};
1515
},
1616

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Attribute, Node } from '@core/index.js';
2+
import dotsLoader from '@harbour-enterprises/common/icons/dots-loader.svg';
3+
import { AiLoaderNodeName } from './ai-constants.js';
4+
5+
export const AiLoaderNode = Node.create({
6+
name: AiLoaderNodeName,
7+
8+
group: 'inline',
9+
10+
inline: true,
11+
12+
atom: true,
13+
14+
selectable: false,
15+
16+
draggable: false,
17+
18+
addOptions() {
19+
return {
20+
htmlAttributes: {
21+
class: 'sd-ai-loader',
22+
contentEditable: 'false',
23+
}
24+
};
25+
},
26+
27+
parseDOM() {
28+
return [{ tag: 'span.sd-ai-loader' }];
29+
},
30+
31+
renderDOM({ htmlAttributes }) {
32+
const span = document.createElement('span');
33+
Object.entries(Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes))
34+
.forEach(([k, v]) => span.setAttribute(k, v));
35+
36+
const img = document.createElement('img');
37+
img.src = dotsLoader;
38+
img.alt = 'loading...';
39+
img.width = 100;
40+
img.height = 50;
41+
span.appendChild(img);
42+
return span;
43+
}
44+
});

0 commit comments

Comments
 (0)