Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,122 @@
<script setup>
import getUuid from 'widget/helpers/uuid';
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/mp3ConversionUtils';
const props = defineProps({
audioRecordFormat: {
type: String,
required: true,
},
});
const emit = defineEmits([
'recorderProgressChanged',
'finishRecord',
'pause',
'play',
]);
const waveformContainer = ref(null);
const wavesurfer = ref(null);
const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
return format(
new Date(0, 0, 0, 0, duration.minutes, duration.seconds),
'mm:ss'
);
};
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
waveColor: '#1F93FF',
progressColor: '#6E6F73',
height: 100,
barWidth: 2,
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
],
});
wavesurfer.value.on('pause', () => emit('pause'));
wavesurfer.value.on('play', () => emit('play'));
record.value = wavesurfer.value.plugins[0];
wavesurfer.value.on('finish', () => {
isPlaying.value = false;
});
record.value.on('record-end', async blob => {
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
});
record.value.on('record-progress', time => {
emit('recorderProgressChanged', formatTimeProgress(time));
});
};
const stopRecording = () => {
if (isRecording.value) {
record.value.stopRecording();
isRecording.value = false;
}
};
const startRecording = () => {
record.value.startRecording();
isRecording.value = true;
};
const playPause = () => {
if (hasRecording.value) {
wavesurfer.value.playPause();
isPlaying.value = !isPlaying.value;
}
};
onMounted(() => {
initWaveSurfer();
startRecording();
});
onUnmounted(() => {
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
});
defineExpose({ playPause, stopRecording, record });
</script>
<template>
<div ref="waveformContainer" class="w-full p-1" />
</template>

View File

@@ -0,0 +1,253 @@
<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)
if (event.key === 'Enter' && !event.shiftKey) {
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>

View File

@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
import { REPLY_EDITOR_MODES } from './constants';
const props = defineProps({
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disabled: {
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
const wootEditorReplyMode = useTemplateRef('wootEditorReplyMode');
const wootEditorPrivateMode = useTemplateRef('wootEditorPrivateMode');
const replyModeSize = useElementSize(wootEditorReplyMode);
const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
* Computes the width of the sliding background chip in pixels
* Includes 16px of padding in the calculation
* @type {ComputedRef<string>}
*/
const width = computed(() => {
const widthToUse = isPrivate.value
? privateModeSize.width.value
: replyModeSize.width.value;
const widthWithPadding = widthToUse + 16;
return `${widthWithPadding}px`;
});
/**
* Computes the X translation value for the sliding background chip
* Translates by the width of reply mode + padding when in private mode
* @type {ComputedRef<string>}
*/
const translateValue = computed(() => {
const xTranslate = isPrivate.value ? replyModeSize.width.value + 16 : 0;
return `${xTranslate}px`;
});
</script>
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</div>
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
'--rtl-translate-x': `calc(-1 * var(--translate-x))`,
}"
/>
</button>
</template>

View File

@@ -0,0 +1,351 @@
<script>
import {
fullSchema,
buildEditor,
EditorView,
ArticleMarkdownSerializer,
ArticleMarkdownTransformer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: buildEditor({
schema: fullSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
let editorView = null;
let state;
export default {
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] },
autofocus: {
type: Boolean,
default: true,
},
},
emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
};
},
watch: {
modelValue(newValue = '') {
if (newValue !== this.contentFromEditor()) {
this.reloadState();
}
},
editorId() {
this.reloadState();
},
},
created() {
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
},
mounted() {
this.createEditorView();
editorView.updateState(state);
if (this.autofocus) {
this.focusEditorInputField();
}
},
methods: {
contentFromEditor() {
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
},
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
async handleImageUpload(url) {
try {
const fileUrl = await this.$store.dispatch(
'articles/uploadExternalImage',
{
portalSlug: this.$route.params.portalSlug,
url,
}
);
return fileUrl;
} catch (error) {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
);
return '';
}
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
size: MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
this.$refs.imageUploadInput.value = '';
},
async uploadImageToStorage(file) {
try {
const fileUrl = await this.$store.dispatch('articles/attachImage', {
portalSlug: this.$route.params.portalSlug,
file,
});
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
} catch (error) {
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
}
},
onImageUploadStart(fileUrl) {
const { selection } = editorView.state;
const from = selection.from;
const node = editorView.state.schema.nodes.image.create({
src: fileUrl,
});
const paragraphNode = editorView.state.schema.node('paragraph');
if (node) {
// Insert the image and the caption wrapped inside a paragraph
const tr = editorView.state.tr
.replaceSelectionWith(paragraphNode)
.insert(from + 1, node);
editorView.dispatch(tr.scrollIntoView());
this.focusEditorInputField();
}
},
reloadState() {
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
editorView.updateState(state);
this.focusEditorInputField();
},
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
this.emitOnChange();
}
this.checkSelection(state);
},
handleDOMEvents: {
keyup: this.onKeyup,
focus: this.onFocus,
blur: this.onBlur,
keydown: this.onKeydown,
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
data.forEach(file => {
// Check if the file is an image
if (file.type.includes('image')) {
this.uploadImageToStorage(file);
}
});
event.preventDefault();
}
},
},
});
},
handleKeyEvents() {},
focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
},
emitOnChange() {
this.$emit('update:modelValue', this.contentFromEditor());
this.$emit('input', this.contentFromEditor());
},
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
this.$emit('keydown');
},
onBlur() {
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
this.isTextSelected = hasSelection;
const { editor } = this.$refs;
// Toggle the 'has-selection' class based on whether there's a selection
editor.classList.toggle('has-selection', hasSelection);
// If there's a selection, update the menubar position
if (hasSelection) this.setMenubarPosition(editorState);
},
setMenubarPosition(editorState) {
if (!editorState.selection) return;
// Get the start and end positions of the selection
const { from, to } = editorState.selection;
const { editor } = this.$refs;
// Get the editor's position relative to the viewport
const { left: editorLeft, top: editorTop } =
editor.getBoundingClientRect();
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
// Get the start position of the selection
const { left: startLeft } = editorView.coordsAtPos(from);
// Calculate the top position for the menubar (10px below the selection)
const top = endBottom - editorTop + 10;
// Calculate the left position for the menubar
// This centers the menubar on the selection while keeping it within the editor's bounds
const left = Math.max(
0,
Math.min(
(startLeft + endRight) / 2 - editorLeft,
editorWidth - menubarWidth
)
);
// Set the CSS custom properties for positioning the menubar
editor.style.setProperty('--selection-top', `${top}px`);
editor.style.setProperty('--selection-left', `${left}px`);
},
},
};
</script>
<template>
<div>
<div class="editor-root editor--article">
<input
ref="imageUploadInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/article.scss';
.ProseMirror-menubar-wrapper {
display: flex;
flex-direction: column;
> .ProseMirror {
padding: 0;
word-break: break-word;
}
}
.editor-root {
position: relative;
width: 100%;
}
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>

View File

@@ -0,0 +1,439 @@
<script>
import { ref } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
default: () => {},
},
sendButtonText: {
type: String,
default: '',
},
recordingAudioDurationText: {
type: String,
default: '00:00',
},
// inbox prop is used in /mixins/inboxMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inbox: {
type: Object,
default: () => ({}),
},
showFileUpload: {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
},
toggleEmojiPicker: {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
},
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
portalSlug: {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(false);
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
if (uploadTriggerButton) uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
wrapClass() {
return {
'is-note-mode': this.isNote,
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
// const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
// navigator.userAgent
// );
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder
// !isSafari
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) {
return this.ALLOWED_FILE_TYPES;
}
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
return getAllowedFileTypesByChannel({
channelType,
medium: this.inbox?.medium,
});
},
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'i-ph-pause';
case 'paused':
return 'i-ph-play';
case 'stopped':
return 'i-ph-play';
default:
return 'i-ph-stop';
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
// channelType is sourced from inboxMixin
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
return this.portalSlug;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
},
methods: {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
},
};
</script>
<template>
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
faded
sm
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="enableMultipleFileUpload"
:drop="enableDragAndDrop"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<NextButton
v-if="showAttachButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="i-ph-paperclip"
slate
faded
sm
/>
</FileUpload>
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
faded
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
slate
faded
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
/>
<NextButton
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature"
slate
faded
sm
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
<NextButton
v-if="enableInsertArticleInReply"
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="i-ph-article-ny-times"
slate
faded
sm
@click="toggleInsertArticle"
/>
</div>
<div class="right-wrap">
<NextButton
:label="sendButtonText"
type="submit"
sm
:color="isNote ? 'amber' : 'blue'"
:disabled="isSendDisabled"
class="flex-shrink-0"
@click="onSend"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.left-wrap {
@apply items-center flex gap-2;
}
.right-wrap {
@apply flex;
}
::v-deep .file-uploads {
label {
@apply cursor-pointer;
}
&:hover button {
@apply enabled:bg-n-slate-9/20;
}
}
</style>

View File

@@ -0,0 +1,190 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
},
charactersRemaining: {
type: Number,
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
if (props.isReplyRestricted) return;
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
setReplyMode(REPLY_EDITOR_MODES.NOTE);
};
const handleModeToggle = () => {
const newMode =
props.mode === REPLY_EDITOR_MODES.REPLY
? REPLY_EDITOR_MODES.NOTE
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
handleModeToggle,
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
replyButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.REPLY,
};
},
noteButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.NOTE,
};
},
charLengthClass() {
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
},
characterLengthWarning() {
return this.charactersRemaining < 0
? `${-this.charactersRemaining} ${CHAR_LENGTH_WARNING.NEGATIVE}`
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
},
},
};
</script>
<template>
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
</span>
</div>
</div>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
export const REPLY_EDITOR_MODES = {
REPLY: 'REPLY',
NOTE: 'NOTE',
};
export const CHAR_LENGTH_WARNING = {
UNDER_50: 'characters remaining',
NEGATIVE: 'characters over',
};

View File

@@ -0,0 +1,70 @@
<script setup>
import { shallowRef, computed, onMounted } from 'vue';
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
import MentionBox from '../mentions/MentionBox.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectEmoji']);
const allEmojis = shallowRef([]);
const items = computed(() => {
if (!props.searchKey) return [];
const searchTerm = props.searchKey.toLowerCase();
return allEmojis.value.filter(emoji =>
emoji.searchString.includes(searchTerm)
);
});
function loadEmojis() {
allEmojis.value = emojiGroups.flatMap(({ emojis }) =>
emojis.map(({ name, slug, ...rest }) => ({
...rest,
name,
slug,
searchString: `${name.replace(/\s+/g, '')} ${slug}`.toLowerCase(), // Remove all whitespace and convert to lowercase
}))
);
}
function handleMentionClick(item = {}) {
emit('selectEmoji', item.emoji);
}
onMounted(() => {
loadEmojis();
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="emoji"
:items="items"
@mention-select="handleMentionClick"
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-n-slate-12 group-hover:text-n-brand truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-n-brand': selected,
'font-normal': !selected,
}"
>
:{{ item.name }}
</p>
</span>
</template>
</MentionBox>
</template>

View File

@@ -0,0 +1,146 @@
import lamejs from '@breezystack/lamejs';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const bufferToWav = async (buffer, numChannels, sampleRate) => {
const length = buffer.length * numChannels * 2;
const wav = new ArrayBuffer(44 + length);
const view = new DataView(wav);
// WAV Header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, length, true);
// WAV Data
const offset = 44;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < buffer.length; i++) {
// eslint-disable-next-line no-plusplus
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(
-1,
Math.min(1, buffer.getChannelData(channel)[i])
);
view.setInt16(
offset + (i * numChannels + channel) * 2,
sample * 0x7fff,
true
);
}
}
return new Blob([wav], { type: 'audio/wav' });
};
const decodeAudioData = async audioBlob => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioData = await audioContext.decodeAudioData(arrayBuffer);
return audioData;
};
export const convertToWav = async audioBlob => {
const audioBuffer = await decodeAudioData(audioBlob);
return bufferToWav(
audioBuffer,
audioBuffer.numberOfChannels,
audioBuffer.sampleRate
);
};
/**
* Encodes audio samples to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples, bitrate = 128) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, bitrate);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
const sampleSlice = samples.subarray(offset, sliceEnd);
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
if (mp3Buffer.length > 0) {
outputBuffer.push(new Int8Array(mp3Buffer));
}
}
const remainingData = encoder.flush();
if (remainingData.length > 0) {
outputBuffer.push(new Int8Array(remainingData));
}
return new Blob(outputBuffer, { type: 'audio/mp3' });
};
/**
* Converts an audio Blob to an MP3 format Blob.
* @param {Blob} audioBlob - The audio data as a Blob.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertToMp3 = async (audioBlob, bitrate = 128) => {
try {
const audioBuffer = await decodeAudioData(audioBlob);
const samples = new Int16Array(
audioBuffer.length * audioBuffer.numberOfChannels
);
let offset = 0;
for (let i = 0; i < audioBuffer.length; i += 1) {
for (
let channel = 0;
channel < audioBuffer.numberOfChannels;
channel += 1
) {
const sample = Math.max(
-1,
Math.min(1, audioBuffer.getChannelData(channel)[i])
);
samples[offset] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
offset += 1;
}
}
return encodeToMP3(
audioBuffer.numberOfChannels,
audioBuffer.sampleRate,
samples,
bitrate
);
} catch (error) {
throw new Error('Conversion to MP3 failed.');
}
};
export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
let audio;
if (outputFormat === 'audio/wav') {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else {
throw new Error('Unsupported output format');
}
return audio;
};