generated from nhcarrigan/template
feat: add resizable chat input with top drag handle
- Added custom resize handle above textarea that expands upward - Fixed send button alignment to stay at bottom of textarea - Replaced native resize with custom drag-to-resize logic - Height constraints: min 48px, max 200px
This commit is contained in:
@@ -39,6 +39,37 @@
|
|||||||
let historyIndex = $state(-1);
|
let historyIndex = $state(-1);
|
||||||
let tempInput = $state("");
|
let tempInput = $state("");
|
||||||
|
|
||||||
|
// Textarea resize state
|
||||||
|
let textareaHeight = $state(48);
|
||||||
|
const MIN_HEIGHT = 48;
|
||||||
|
const MAX_HEIGHT = 200;
|
||||||
|
let isResizing = $state(false);
|
||||||
|
let startY = 0;
|
||||||
|
let startHeight = 0;
|
||||||
|
|
||||||
|
function handleResizeStart(event: MouseEvent) {
|
||||||
|
isResizing = true;
|
||||||
|
startY = event.clientY;
|
||||||
|
startHeight = textareaHeight;
|
||||||
|
document.addEventListener("mousemove", handleResizeMove);
|
||||||
|
document.addEventListener("mouseup", handleResizeEnd);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResizeMove(event: MouseEvent) {
|
||||||
|
if (!isResizing) return;
|
||||||
|
// Dragging up (negative deltaY) should increase height
|
||||||
|
const deltaY = startY - event.clientY;
|
||||||
|
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
|
||||||
|
textareaHeight = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResizeEnd() {
|
||||||
|
isResizing = false;
|
||||||
|
document.removeEventListener("mousemove", handleResizeMove);
|
||||||
|
document.removeEventListener("mouseup", handleResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
// Load history from localStorage on init
|
// Load history from localStorage on init
|
||||||
function loadHistory(): string[] {
|
function loadHistory(): string[] {
|
||||||
try {
|
try {
|
||||||
@@ -314,13 +345,19 @@ User: ${formattedMessage}`;
|
|||||||
<MessageModeSelector />
|
<MessageModeSelector />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-row flex gap-3 items-end">
|
<div class="input-row">
|
||||||
<div class="flex-1 relative">
|
<div class="textarea-wrapper">
|
||||||
<SlashCommandMenu
|
<SlashCommandMenu
|
||||||
commands={matchingCommands}
|
commands={matchingCommands}
|
||||||
selectedIndex={selectedCommandIndex}
|
selectedIndex={selectedCommandIndex}
|
||||||
onSelect={selectCommand}
|
onSelect={selectCommand}
|
||||||
/>
|
/>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
onmousedown={handleResizeStart}
|
||||||
|
title="Drag to resize"
|
||||||
|
></div>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
onkeydown={handleKeyDown}
|
onkeydown={handleKeyDown}
|
||||||
@@ -330,41 +367,39 @@ User: ${formattedMessage}`;
|
|||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
style="height: {textareaHeight}px"
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
transition-all duration-200"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isProcessing}
|
<div class="button-wrapper">
|
||||||
<button
|
{#if isProcessing}
|
||||||
type="button"
|
<button
|
||||||
onclick={handleInterrupt}
|
type="button"
|
||||||
class="px-6 py-3 bg-red-600 hover:bg-red-700
|
onclick={handleInterrupt}
|
||||||
text-white font-medium rounded-lg
|
class="send-button bg-red-600 hover:bg-red-700"
|
||||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
title="Interrupt the current response (Ctrl+C)"
|
||||||
title="Interrupt the current response (Ctrl+C)"
|
>
|
||||||
>
|
<span class="font-bold">■</span> Stop
|
||||||
<span class="font-bold">■</span> Stop
|
</button>
|
||||||
</button>
|
{:else}
|
||||||
{:else}
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||||
class="px-6 py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
text-white font-medium rounded-lg
|
>
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
{#if isSubmitting}
|
||||||
transition-all duration-200 transform hover:scale-105 active:scale-95"
|
<span class="inline-block animate-spin">⏳</span>
|
||||||
>
|
{:else}
|
||||||
{#if isSubmitting}
|
Send
|
||||||
<span class="inline-block animate-spin">⏳</span>
|
{/if}
|
||||||
{:else}
|
</button>
|
||||||
Send
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -386,4 +421,61 @@ User: ${formattedMessage}`;
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
height: 6px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle::before {
|
||||||
|
content: "";
|
||||||
|
width: 40px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 48px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user