# Pull Request Template ## Description CJK language users (Chinese, Japanese, Korean, etc.) use IME where Enter confirms character selection. AI input components were intercepting Enter unconditionally, making them unusable for IME users. Add `event.isComposing` check to CopilotEditor, CopilotInput, and AssistantPlayground so Enter during active IME composition is ignored. ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Before: Add a Japenese keyboard, then go to AI follow-ups, type some word, selecting it with enter submits the follow up. So CJK users cannot use follow-ups. https://github.com/user-attachments/assets/53517432-d97b-47fc-a802-81675e31d5c9 After: Type a word, press enter to choose it, press enter again to unselect it and enter again to send https://github.com/user-attachments/assets/6c2a420b-7ee6-4c71-82a6-d9f1d7bbf31a ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
5.3 KiB
Vue
255 lines
5.3 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
|
|
|
|
import {
|
|
buildMessageSchema,
|
|
buildEditor,
|
|
EditorView,
|
|
MessageMarkdownTransformer,
|
|
MessageMarkdownSerializer,
|
|
EditorState,
|
|
Selection,
|
|
} from '@chatwoot/prosemirror-schema';
|
|
|
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
|
|
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: String, default: '' },
|
|
editorId: { type: String, default: '' },
|
|
placeholder: {
|
|
type: String,
|
|
default: 'Give copilot additional prompts, or ask anything else...',
|
|
},
|
|
generatedContent: { type: String, default: '' },
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
isPopout: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
'blur',
|
|
'input',
|
|
'update:modelValue',
|
|
'keyup',
|
|
'focus',
|
|
'keydown',
|
|
'send',
|
|
]);
|
|
|
|
const { formatMessage } = useMessageFormatter();
|
|
|
|
// Minimal schema with no marks or nodes for copilot input
|
|
const copilotSchema = buildMessageSchema([], []);
|
|
|
|
const handleSubmit = () => emit('send');
|
|
|
|
const createState = (
|
|
content,
|
|
placeholder,
|
|
plugins = [],
|
|
enabledMenuOptions = []
|
|
) => {
|
|
return EditorState.create({
|
|
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
|
|
plugins: buildEditor({
|
|
schema: copilotSchema,
|
|
placeholder,
|
|
plugins,
|
|
enabledMenuOptions,
|
|
}),
|
|
});
|
|
};
|
|
|
|
// we don't need them to be reactive
|
|
// It cases weird issues where the objects are proxied
|
|
// and then the editor doesn't work as expected
|
|
let editorView = null;
|
|
let state = null;
|
|
|
|
// reactive data
|
|
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
|
|
|
// element refs
|
|
const editor = useTemplateRef('editor');
|
|
|
|
function contentFromEditor() {
|
|
if (editorView) {
|
|
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function focusEditorInputField() {
|
|
const { tr } = editorView.state;
|
|
const selection = Selection.atEnd(tr.doc);
|
|
|
|
editorView.dispatch(tr.setSelection(selection));
|
|
editorView.focus();
|
|
}
|
|
|
|
function emitOnChange() {
|
|
emit('update:modelValue', contentFromEditor());
|
|
emit('input', contentFromEditor());
|
|
}
|
|
|
|
function onKeyup() {
|
|
emit('keyup');
|
|
}
|
|
|
|
function onKeydown(view, event) {
|
|
emit('keydown');
|
|
|
|
// Handle Enter key to send message (Shift+Enter for new line)
|
|
// Skip if IME composition is active (CJK character confirmation)
|
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
event.preventDefault();
|
|
handleSubmit();
|
|
return true; // Prevent ProseMirror's default Enter handling
|
|
}
|
|
|
|
return false; // Allow other keys to work normally
|
|
}
|
|
|
|
function onBlur() {
|
|
emit('blur');
|
|
}
|
|
|
|
function onFocus() {
|
|
emit('focus');
|
|
}
|
|
|
|
function checkSelection(editorState) {
|
|
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
|
if (hasSelection === isTextSelected.value) return;
|
|
isTextSelected.value = hasSelection;
|
|
}
|
|
|
|
// computed properties
|
|
const plugins = computed(() => {
|
|
return [];
|
|
});
|
|
|
|
const enabledMenuOptions = computed(() => {
|
|
return [];
|
|
});
|
|
|
|
function reloadState() {
|
|
state = createState(
|
|
props.modelValue,
|
|
props.placeholder,
|
|
plugins.value,
|
|
enabledMenuOptions.value
|
|
);
|
|
editorView.updateState(state);
|
|
focusEditorInputField();
|
|
}
|
|
|
|
function createEditorView() {
|
|
editorView = new EditorView(editor.value, {
|
|
state: state,
|
|
dispatchTransaction: tx => {
|
|
state = state.apply(tx);
|
|
editorView.updateState(state);
|
|
if (tx.docChanged) {
|
|
emitOnChange();
|
|
}
|
|
checkSelection(state);
|
|
},
|
|
handleDOMEvents: {
|
|
keyup: onKeyup,
|
|
focus: onFocus,
|
|
blur: onBlur,
|
|
keydown: onKeydown,
|
|
},
|
|
});
|
|
}
|
|
|
|
// watchers
|
|
watch(
|
|
computed(() => props.modelValue),
|
|
(newValue = '') => {
|
|
if (newValue !== contentFromEditor()) {
|
|
reloadState();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
computed(() => props.editorId),
|
|
() => {
|
|
reloadState();
|
|
}
|
|
);
|
|
|
|
// lifecycle
|
|
onMounted(() => {
|
|
state = createState(
|
|
props.modelValue,
|
|
props.placeholder,
|
|
plugins.value,
|
|
enabledMenuOptions.value
|
|
);
|
|
|
|
createEditorView();
|
|
editorView.updateState(state);
|
|
|
|
if (props.autofocus) {
|
|
focusEditorInputField();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-2 mb-4">
|
|
<div
|
|
class="overflow-y-auto"
|
|
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
|
>
|
|
<p
|
|
v-dompurify-html="formatMessage(generatedContent, false)"
|
|
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
|
/>
|
|
</div>
|
|
<div class="editor-root relative editor--copilot space-x-2">
|
|
<div ref="editor" />
|
|
<div class="flex items-center justify-end absolute right-2 bottom-2">
|
|
<NextButton
|
|
class="bg-n-iris-9 text-white !rounded-full"
|
|
icon="i-lucide-arrow-up"
|
|
solid
|
|
sm
|
|
@click="handleSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
|
|
|
.editor--copilot {
|
|
@apply bg-n-iris-5 rounded;
|
|
|
|
.ProseMirror-woot-style {
|
|
min-height: 5rem;
|
|
max-height: 7.5rem !important;
|
|
overflow: auto;
|
|
@apply px-2 !important;
|
|
|
|
.empty-node {
|
|
&::before {
|
|
@apply text-n-iris-9 dark:text-n-iris-11;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|