thelounge/client/components/ChatInput.vue

359 lines
8.3 KiB
Vue
Raw Normal View History

2018-07-08 13:42:54 +00:00
<template>
2019-07-17 09:33:59 +00:00
<form id="form" method="post" action="" @submit.prevent="onSubmit">
2019-12-04 06:58:23 +00:00
<span id="upload-progressbar" />
2018-07-08 13:42:54 +00:00
<span id="nick">{{ network.nick }}</span>
<textarea
id="input"
2018-07-08 15:17:20 +00:00
ref="input"
dir="auto"
class="mousetrap"
2020-08-29 08:46:11 +00:00
enterkeyhint="send"
2018-08-29 07:34:21 +00:00
:value="channel.pendingMessage"
2018-07-08 13:42:54 +00:00
:placeholder="getInputPlaceholder(channel)"
:aria-label="getInputPlaceholder(channel)"
2018-08-29 07:34:21 +00:00
@input="setPendingMessage"
@keypress.enter.exact.prevent="onSubmit"
@blur="onBlur"
/>
2018-09-03 07:58:33 +00:00
<span
v-if="store.state.serverConfiguration?.fileUpload"
2018-09-03 07:58:33 +00:00
id="upload-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
2019-02-18 07:19:44 +00:00
aria-label="Upload file"
@click="openFileUpload"
>
<input
id="upload-input"
ref="uploadInput"
type="file"
aria-labelledby="upload"
multiple
@change="onUploadInputChange"
/>
2018-09-03 07:58:33 +00:00
<button
id="upload"
type="button"
aria-label="Upload file"
:disabled="!store.state.isConnected"
/>
2018-09-03 07:58:33 +00:00
</span>
2018-07-08 13:42:54 +00:00
<span
id="submit-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Send message"
>
2018-07-08 13:42:54 +00:00
<button
id="submit"
type="submit"
2018-09-12 19:54:20 +00:00
aria-label="Send message"
:disabled="!store.state.isConnected"
/>
2018-07-08 13:42:54 +00:00
</span>
</form>
</template>
<script lang="ts">
2019-11-16 17:24:03 +00:00
import Mousetrap from "mousetrap";
import {wrapCursor} from "undate";
2019-11-15 18:53:38 +00:00
import autocompletion from "../js/autocompletion";
import {commands} from "../js/commands/index";
2019-11-16 17:24:03 +00:00
import socket from "../js/socket";
import upload from "../js/upload";
2020-03-16 17:58:40 +00:00
import eventbus from "../js/eventbus";
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
2024-05-02 06:21:51 +00:00
import {ChanType} from "../../shared/types/chan";
2018-07-09 18:51:27 +00:00
2018-10-13 10:11:38 +00:00
const formattingHotkeys = {
"mod+k": "\x03",
"mod+b": "\x02",
"mod+u": "\x1F",
"mod+i": "\x1D",
"mod+o": "\x0F",
"mod+s": "\x1e",
"mod+m": "\x11",
2018-07-09 18:51:27 +00:00
};
// Autocomplete bracket and quote characters like in a modern IDE
// For example, select `text`, press `[` key, and it becomes `[text]`
const bracketWraps = {
'"': '"',
"'": "'",
"(": ")",
"<": ">",
"[": "]",
"{": "}",
"*": "*",
"`": "`",
"~": "~",
2019-07-17 09:33:59 +00:00
_: "_",
2018-07-09 18:51:27 +00:00
};
2018-07-08 15:17:20 +00:00
export default defineComponent({
2018-07-08 13:42:54 +00:00
name: "ChatInput",
props: {
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
2018-07-13 10:24:05 +00:00
},
setup(props) {
const store = useStore();
const input = ref<HTMLTextAreaElement>();
const uploadInput = ref<HTMLInputElement>();
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
const setInputSize = () => {
void nextTick(() => {
if (!input.value) {
return;
2018-09-09 12:23:12 +00:00
}
const style = window.getComputedStyle(input.value);
const lineHeight = parseFloat(style.lineHeight) || 1;
2018-08-29 07:34:21 +00:00
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
input.value.style.height = "";
2018-08-29 07:34:21 +00:00
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
// because some browsers tend to incorrently round the values when using high density
// displays or using page zoom feature
input.value.style.height = `${
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
}px`;
2018-08-29 07:34:21 +00:00
});
};
const setPendingMessage = (e: Event) => {
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
props.channel.inputHistoryPosition = 0;
setInputSize();
};
const getInputPlaceholder = (channel: ClientChan) => {
2024-05-02 06:21:51 +00:00
if (channel.type === ChanType.CHANNEL || channel.type === ChanType.QUERY) {
2018-07-08 13:42:54 +00:00
return `Write to ${channel.name}`;
}
return "";
};
const onSubmit = () => {
if (!input.value) {
return;
}
2018-07-08 15:17:20 +00:00
// Triggering click event opens the virtual keyboard on mobile
// This can only be called from another interactive event (e.g. button click)
input.value.click();
input.value.focus();
2018-07-08 15:17:20 +00:00
if (!store.state.isConnected) {
2018-08-15 09:00:54 +00:00
return false;
}
const target = props.channel.id;
const text = props.channel.pendingMessage;
2018-07-08 15:17:20 +00:00
if (text.length === 0) {
return false;
}
if (autocompletionRef.value) {
autocompletionRef.value.hide();
2019-11-15 18:53:38 +00:00
}
props.channel.inputHistoryPosition = 0;
props.channel.pendingMessage = "";
input.value.value = "";
setInputSize();
2018-07-08 15:17:20 +00:00
2018-09-09 12:23:12 +00:00
// Store new message in history if last message isn't already equal
if (props.channel.inputHistory[1] !== text) {
props.channel.inputHistory.splice(1, 0, text);
2018-09-09 12:23:12 +00:00
}
2018-09-12 20:35:55 +00:00
// Limit input history to a 100 entries
if (props.channel.inputHistory.length > 100) {
props.channel.inputHistory.pop();
2018-09-12 20:35:55 +00:00
}
if (text[0] === "/") {
const args = text.substring(1).split(" ");
const cmd = args.shift()?.toLowerCase();
if (!cmd) {
return false;
}
2018-07-08 15:17:20 +00:00
if (Object.prototype.hasOwnProperty.call(commands, cmd) && commands[cmd](args)) {
2018-07-08 15:17:20 +00:00
return false;
}
}
socket.emit("input", {target, text});
};
const onUploadInputChange = () => {
if (!uploadInput.value || !uploadInput.value.files) {
return;
}
const files = Array.from(uploadInput.value.files);
upload.triggerUpload(files);
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
};
const openFileUpload = () => {
uploadInput.value?.click();
};
const blurInput = () => {
input.value?.blur();
};
const onBlur = () => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
};
watch(
() => props.channel.id,
() => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
}
);
watch(
() => props.channel.pendingMessage,
() => {
setInputSize();
}
);
onMounted(() => {
eventbus.on("escapekey", blurInput);
if (store.state.settings.autocomplete) {
if (!input.value) {
throw new Error("ChatInput autocomplete: input element is not available");
}
autocompletionRef.value = autocompletion(input.value);
}
const inputTrap = Mousetrap(input.value);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
if (!e.target) {
return;
}
wrapCursor(
e.target as HTMLTextAreaElement,
modifier,
(e.target as HTMLTextAreaElement).selectionStart ===
(e.target as HTMLTextAreaElement).selectionEnd
? ""
: modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (
(e.target as HTMLTextAreaElement)?.selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd
) {
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
store.state.isAutoCompleting ||
(e.target as HTMLTextAreaElement).selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd ||
!input.value
) {
return;
}
const onRow = (
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
[]
).length;
const totalRows = (input.value.value.match(/\n/g) || []).length;
const {channel} = props;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} else if (
key === "down" &&
channel.inputHistoryPosition > 0 &&
onRow === totalRows
) {
channel.inputHistoryPosition--;
} else {
return;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
input.value.value = channel.pendingMessage;
setInputSize();
return false;
});
if (store.state.serverConfiguration?.fileUpload) {
upload.mounted();
}
});
onUnmounted(() => {
eventbus.off("escapekey", blurInput);
if (autocompletionRef.value) {
autocompletionRef.value.destroy();
autocompletionRef.value = undefined;
}
upload.unmounted();
upload.abort();
});
return {
store,
input,
uploadInput,
onUploadInputChange,
openFileUpload,
blurInput,
onBlur,
setInputSize,
upload,
getInputPlaceholder,
onSubmit,
setPendingMessage,
};
2018-07-08 13:42:54 +00:00
},
});
2018-07-08 13:42:54 +00:00
</script>