- Add textarea for input

- Remove channel switch button
- Integrate channel switch selection into channel name
- Fix state() warnings
This commit is contained in:
Infi
2025-09-24 22:57:22 +02:00
parent e2df709003
commit 11311316fd
10 changed files with 194 additions and 90 deletions
@@ -0,0 +1,79 @@
<script lang="ts">
import {isChannelLocked, channelOptions} from "$lib/shared.svelte";
let selectElement: HTMLSelectElement;
async function requestChannelSwitch(event: Event) {
if (!event.currentTarget)
return;
let element = (event.currentTarget as HTMLSelectElement);
let requestedChannel = element.value;
console.log(element.value)
element.value = '0';
const rawResponse = await fetch('/channel', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: requestedChannel })
});
// const content = await rawResponse.json();
// TODO: use the response
}
let canvas: HTMLCanvasElement | null = null;
function getTextWidth(text: string): number {
// re-use canvas object for better performance
if (canvas === null)
canvas = document.createElement("canvas");
const context: CanvasRenderingContext2D | null = canvas.getContext("2d");
if (!context)
return 0;
context.font = getCanvasFont(selectElement);
const metrics = context.measureText(text);
return metrics.width;
}
function getCssStyle(element: Element, prop: string): string {
return window.getComputedStyle(element, null).getPropertyValue(prop);
}
function getCanvasFont(el = document.body) {
const fontWeight = getCssStyle(el, 'font-weight') || 'normal';
const fontSize = getCssStyle(el, 'font-size') || '16px';
const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman';
return `${fontWeight} ${fontSize} ${fontFamily}`;
}
</script>
<select
bind:this={selectElement}
id="channel-select"
style="pointer-events: {isChannelLocked.locked ? 'none' : 'inherit'}; width: {(channelOptions.length > 1 ? getTextWidth(channelOptions[0].text) : 1) + 40}px"
onchange={(e) => requestChannelSwitch(e)}>
{#each channelOptions as channelOption}
{#if channelOption.preview }
<option selected disabled hidden value={channelOption.value}>
{channelOption.text}
</option>
{:else}
<option value={channelOption.value}>
{channelOption.text}
</option>
{/if}
{/each}
</select>
<style>
select {
border: none;
background-color: transparent;
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
let textarea: HTMLTextAreaElement;
let content: string = $state('');
function preventNewlines(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Prevent key from creating a newline
e.preventDefault();
// submit the data
const newEvent = new Event('submit', {bubbles: true, cancelable: true});
if (e.currentTarget !== null) {
(e.currentTarget as HTMLTextAreaElement).closest('form')?.dispatchEvent(newEvent);
}
}
}
function resize() {
if (!textarea)
return;
textarea.style.height = '1px';
textarea.style.height = `${textarea.scrollHeight}px`;
}
</script>
<textarea
bind:this={textarea}
bind:value={content}
oninput={() => resize()}
onkeydown={(e) => preventNewlines(e)}
id="chat-input"
autocomplete="off"
placeholder="Message"
enterkeyhint="send"
maxlength="500">
</textarea>
<style>
textarea {
flex-grow: 0;
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
width: 100%;
padding: 5px 20px;
}
</style>
@@ -1,9 +1,7 @@
import {type Source, source} from "sveltekit-sse"; import {type Source, source} from "sveltekit-sse";
import {channelOptions, isChannelLocked} from "$lib/shared.svelte";
interface ChatElements { interface ChatElements {
channelHint: HTMLElement | null,
channelSelect: HTMLElement | null,
messagesContainer: Element | null, messagesContainer: Element | null,
messagesList: HTMLElement | null, messagesList: HTMLElement | null,
@@ -35,6 +33,7 @@ interface Template {
// ref `DataStructure.SwitchChannel` // ref `DataStructure.SwitchChannel`
interface SwitchChannel { interface SwitchChannel {
channelName: Template[]; channelName: Template[];
channelValue: number;
channelLocked: boolean; channelLocked: boolean;
} }
@@ -58,7 +57,6 @@ export class ChatTwoWeb {
elements!: ChatElements; elements!: ChatElements;
maxTimestampWidth: number = 0; maxTimestampWidth: number = 0;
scrolledToBottom: boolean = true; scrolledToBottom: boolean = true;
channelLocked: boolean = false;
sse!: EventSource; sse!: EventSource;
connection!: Source; connection!: Source;
@@ -70,8 +68,8 @@ export class ChatTwoWeb {
setupDOMElements() { setupDOMElements() {
this.elements = { this.elements = {
channelHint: document.getElementById('channel-hint'), // channelHint: document.getElementById('channel-hint'),
channelSelect: document.getElementById('channel-select'), // channelSelect: document.getElementById('channel-select'),
messagesContainer: document.querySelector('#messages > .scroll-container')!, messagesContainer: document.querySelector('#messages > .scroll-container')!,
messagesList: document.getElementById('messages-list'), messagesList: document.getElementById('messages-list'),
@@ -82,23 +80,6 @@ export class ChatTwoWeb {
chatInput: document.getElementById('chat-input') chatInput: document.getElementById('chat-input')
}; };
// channel selector
this.elements.channelSelect?.addEventListener('change', async (event) => {
if (event.currentTarget === null)
return;
const rawResponse = await fetch('/channel', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: (event.currentTarget as HTMLSelectElement).value })
});
// const content = await rawResponse.json();
// TODO: use the response
});
// add indicator signaling more messages below // add indicator signaling more messages below
this.elements.messagesContainer?.addEventListener('scroll', (event) => { this.elements.messagesContainer?.addEventListener('scroll', (event) => {
if (event.currentTarget === null) if (event.currentTarget === null)
@@ -167,39 +148,25 @@ export class ChatTwoWeb {
} }
updateChannelHint(channel: SwitchChannel) { updateChannelHint(channel: SwitchChannel) {
if (this.elements.channelHint === null || this.elements.channelSelect === null) // Set storage to the current lock state
return; isChannelLocked.locked = channel.channelLocked;
this.elements.channelHint.innerHTML = '';
const channelElement = this.processTemplate(channel.channelName); const channelElement = this.processTemplate(channel.channelName);
if (!channelElement.firstChild)
// Makes the channel selector unclickable if the channel is fixed
this.channelLocked = channel.channelLocked;
if (this.channelLocked) {
if (channelElement.firstChild === null)
return; return;
// @ts-ignore let channelName = (channelElement.firstChild as HTMLSpanElement).innerText;
channelElement.firstChild.innerText = `(Locked) ${channelElement.firstChild.innerText}`; if (channel.channelLocked)
this.elements.channelSelect.style.pointerEvents = 'none'; channelName = `(Locked) ${channelName}`;
} else {
this.elements.channelSelect.style.removeProperty('pointer-events');
}
this.elements.channelHint.appendChild(channelElement); channelOptions[0] = {text: channelName, value: 0, preview: true }
} }
updateChannels(channelList: ChannelList) { updateChannels(channelList: ChannelList) {
if (this.elements.channelSelect === null) channelOptions.length = 1;
return;
this.elements.channelSelect.innerHTML = '';
for (const [ label, channel ] of Object.entries(channelList.channels)) { for (const [ label, channel ] of Object.entries(channelList.channels)) {
const option = document.createElement('option'); channelOptions.push( { text: label, value: channel, preview: false } )
option.value = channel.toString();
option.innerText = label;
this.elements.channelSelect.appendChild(option);
} }
} }
@@ -0,0 +1,8 @@
export const isChannelLocked: { locked: boolean } = $state({ locked: false });
export const channelOptions: ChannelOption[] = $state([ { text: 'Invalid', value: 0, preview: true } ]);
export interface ChannelOption {
text: string;
value: number;
preview: boolean;
}
@@ -2,7 +2,7 @@
import { page } from '$app/state' import { page } from '$app/state'
import { Alert } from '@sveltestrap/sveltestrap'; import { Alert } from '@sveltestrap/sveltestrap';
let data: App.Warning | null = null; let data: App.Warning = $state({ hasWarning: false, content: '' });
$effect.pre(() => { $effect.pre(() => {
if (page.url.searchParams.has('message')) { if (page.url.searchParams.has('message')) {
data = { data = {
@@ -2,10 +2,12 @@
import { page } from '$app/state' import { page } from '$app/state'
import {Alert} from "@sveltestrap/sveltestrap"; import {Alert} from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { ChatTwoWeb } from '$lib/chat' import {ChatTwoWeb} from '$lib/chat.svelte'
import {addGfdStylesheet} from "$lib/gfd"; import {addGfdStylesheet} from "$lib/gfd";
import DynamicTextArea from "../../components/DynamicTextArea.svelte";
import ChannelSelector from "../../components/ChannelSelector.svelte";
let data: App.Warning | null = null; let data: App.Warning = $state({ hasWarning: false, content: '' });
$effect.pre(() => { $effect.pre(() => {
if (page.url.searchParams.has('message')) { if (page.url.searchParams.has('message')) {
data = { data = {
@@ -51,13 +53,9 @@
<section id="input"> <section id="input">
<form> <form>
<div class="select-container">
<select id="channel-select"></select>
</div>
<div class="input-container"> <div class="input-container">
<input type="text" id="chat-input" autocomplete="off" placeholder="Message" enterkeyhint="send" maxlength="500"> <DynamicTextArea/>
<div id="channel-hint"></div> <ChannelSelector/>
</div> </div>
<button type="submit">Send</button> <button type="submit">Send</button>
+24 -30
View File
@@ -183,7 +183,7 @@ section#input {
gap: 10px; gap: 10px;
} }
input, button, select { input, button {
font-size: 1rem; font-size: 1rem;
border: 3px solid transparent; border: 3px solid transparent;
border-radius: 20px; border-radius: 20px;
@@ -194,7 +194,7 @@ section#input {
} }
} }
button, select { button {
padding: 5px 15px; padding: 5px 15px;
border: 3px solid var(--bg-input); border: 3px solid var(--bg-input);
background-image: var(--gradient-clickable); background-image: var(--gradient-clickable);
@@ -207,29 +207,12 @@ section#input {
} }
} }
.select-container, button { button {
position: relative; position: relative;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.select-container {
flex-basis: 0;
&::before {
/* "message-circle" icon from https://github.com/feathericons/feather, under MIT license */
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
}
select {
width: 100%;
padding-left: 1.5rem;
padding-right: 1.5rem;
appearance: none;
color: transparent;
}
}
button { button {
padding-left: calc(20px + 1.5rem); padding-left: calc(20px + 1.5rem);
@@ -239,7 +222,7 @@ section#input {
} }
} }
.select-container::before, button::before { button::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 15px; left: 15px;
@@ -252,11 +235,6 @@ section#input {
pointer-events: none; pointer-events: none;
} }
.select-container::before {
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.input-container { .input-container {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -266,9 +244,9 @@ section#input {
padding: 5px 20px; padding: 5px 20px;
} }
#channel-hint { #channel-select {
position: absolute; position: absolute;
top: -1.2em; top: -1.5em;
left: 23px; left: 23px;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
@@ -276,7 +254,23 @@ section#input {
font-weight: 550; font-weight: 550;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
pointer-events: none;
border: 0;
border-radius: 0;
background-color: transparent;
padding: 5px 15px;
cursor: pointer;
&:hover {
border-color: var(--bg-input-hover);
background-color: var(--bg-input-hover);
background-image: var(--gradient-clickable-hover);
}
&:focus {
outline: 2px solid var(--focus-color);
}
} }
} }
@@ -360,7 +354,7 @@ section#input {
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
} }
.input-container #channel-hint { .input-container #channel-select {
font-size: 0.9rem; font-size: 0.9rem;
} }
} }
@@ -24,9 +24,9 @@ public struct ChatTabList(ChatTab[] tabs)
/// <summary> /// <summary>
/// Contains the current channel name /// Contains the current channel name
/// </summary> /// </summary>
public struct SwitchChannel((MessageTemplate[] ChannelName, bool Locked) channel) public struct SwitchChannel((MessageTemplate[] Name, bool Locked) channel)
{ {
[JsonProperty("channelName")] public MessageTemplate[] ChannelName = channel.ChannelName; [JsonProperty("channelName")] public MessageTemplate[] ChannelName = channel.Name;
[JsonProperty("channelLocked")] public bool Locked = channel.Locked; [JsonProperty("channelLocked")] public bool Locked = channel.Locked;
} }
+1 -1
View File
@@ -15,7 +15,7 @@ public class Processing
Plugin = plugin; Plugin = plugin;
} }
internal (MessageTemplate[] ChannelName, bool Locked) ReadChannelName(Chunk[] channelName) internal (MessageTemplate[] Name, bool Locked) ReadChannelName(Chunk[] channelName)
{ {
var locked = Plugin.CurrentTab is not { Channel: null }; var locked = Plugin.CurrentTab is not { Channel: null };
return (channelName.Select(ProcessChunk).ToArray(), locked); return (channelName.Select(ProcessChunk).ToArray(), locked);
+2 -1
View File
@@ -1,4 +1,5 @@
using ChatTwo.Http.MessageProtocol; using ChatTwo.Code;
using ChatTwo.Http.MessageProtocol;
namespace ChatTwo.Http; namespace ChatTwo.Http;