From 6b46c49c57e9d727799ddc8382c3bb2a307aff74 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:19:17 +0100 Subject: [PATCH 1/3] fuzzy babies --- src/lib/utils/fuzzy-search.ts | 64 ++++++++++++++++++++++++++ src/routes/account/models/+page.svelte | 8 +--- 2 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/lib/utils/fuzzy-search.ts diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts new file mode 100644 index 0000000..69299fe --- /dev/null +++ b/src/lib/utils/fuzzy-search.ts @@ -0,0 +1,64 @@ +/** + * Generic fuzzy search function that searches through arrays and returns matching items + * + * @param options Configuration object for the fuzzy search + * @returns Array of items that match the search criteria + */ +export default function fuzzysearch(options: { + needle: string; + haystack: T[]; + property: keyof T | ((item: T) => string); +}): T[] { + const { needle, haystack, property } = options; + + if (!Array.isArray(haystack)) { + throw new Error('Haystack must be an array'); + } + + if (!property) { + throw new Error('Property selector is required'); + } + + // Convert needle to lowercase for case-insensitive matching + const lowerNeedle = needle.toLowerCase(); + + // Filter the haystack to find matching items + return haystack.filter((item) => { + // Extract the string value from the item based on the property selector + const value = typeof property === 'function' ? property(item) : String(item[property]); + + // Convert to lowercase for case-insensitive matching + const lowerValue = value.toLowerCase(); + + // Perform the fuzzy search + return fuzzyMatchString(lowerNeedle, lowerValue); + }); +} + +/** + * Internal helper function that performs the actual fuzzy string matching + */ +function fuzzyMatchString(needle: string, haystack: string): boolean { + const hlen = haystack.length; + const nlen = needle.length; + + if (nlen > hlen) { + return false; + } + + if (nlen === hlen) { + return needle === haystack; + } + + outer: for (let i = 0, j = 0; i < nlen; i++) { + const nch = needle.charCodeAt(i); + while (j < hlen) { + if (haystack.charCodeAt(j++) === nch) { + continue outer; + } + } + return false; + } + + return true; +} diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 8329efe..003c1ec 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -11,6 +11,7 @@ import XIcon from '~icons/lucide/x'; import PlusIcon from '~icons/lucide/plus'; import { models } from '$lib/state/models.svelte'; + import fuzzysearch from '$lib/utils/fuzzy-search'; const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { provider: Provider.OpenRouter, @@ -30,12 +31,7 @@ }); const openRouterModels = $derived( - models.from(Provider.OpenRouter).filter((model) => { - if (search !== '' && !hasOpenRouterKey) return false; - if (!openRouterToggle.value) return false; - - return model.name.toLowerCase().includes(search.toLowerCase()); - }) + fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' }) ); From 40bce7bfbd467423bcc463599a202d206f44d148 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:42:51 +0100 Subject: [PATCH 2/3] a bunch of qol stuff --- README.md | 1 + src/app.css | 166 ++++++++++++++++ src/lib/attachments/click-outside.svelte.ts | 48 +++++ src/lib/backend/convex/messages.ts | 2 +- src/lib/backend/convex/user_rules.ts | 2 +- src/lib/components/ui/modal/index.ts | 3 + src/lib/components/ui/modal/modal.svelte | 35 ++++ .../components/ui/textarea/textarea.svelte | 12 +- src/lib/components/ui/tooltip.svelte | 71 +++++++ src/lib/spells/textarea-autosize.svelte.ts | 182 ++++++++++++++++++ src/routes/chat/+layout.svelte | 5 + 11 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 src/lib/attachments/click-outside.svelte.ts create mode 100644 src/lib/components/ui/modal/index.ts create mode 100644 src/lib/components/ui/modal/modal.svelte create mode 100644 src/lib/components/ui/tooltip.svelte create mode 100644 src/lib/spells/textarea-autosize.svelte.ts diff --git a/README.md b/README.md index f96b1a2..5796910 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ IDK, calm down - [ ] delete conversations option - [ ] conversation title generation - [ ] kbd powered popover model picker +- [ ] autosize ### Extra diff --git a/src/app.css b/src/app.css index a9903bf..40ab3f5 100644 --- a/src/app.css +++ b/src/app.css @@ -239,3 +239,169 @@ .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari, and Opera */ } + +@layer components { + /* Modal is from DaisyUI */ + .modal { + @apply pointer-events-none invisible fixed inset-0 m-0 grid h-full max-h-none w-full max-w-none items-center justify-items-center bg-transparent p-0 text-[inherit]; + overflow-x: hidden; + transition: + translate 0.3s ease-out, + visibility 0.3s allow-discrete, + background-color 0.3s ease-out, + opacity 0.1s ease-out; + overflow-y: hidden; + overscroll-behavior: contain; + z-index: 999; + &::backdrop { + @apply hidden; + } + + &.modal-open, + &[open], + &:target { + @apply pointer-events-auto visible opacity-100; + background-color: oklch(0% 0 0/ 0.4); + /* this cause glitch on Chrome */ + /* transition: + translate 0.3s ease-out, + background-color 0.3s ease-out, + opacity 0.1s ease-out; */ + .modal-box { + translate: 0 0; + scale: 1; + opacity: 1; + } + } + @starting-style { + &.modal-open, + &[open], + &:target { + @apply invisible opacity-0; + } + } + } + + .modal-action { + @apply mt-6 flex justify-end gap-2; + } + + .modal-toggle { + @apply fixed h-0 w-0 appearance-none opacity-0; + + &:checked + .modal { + @apply pointer-events-auto visible opacity-100; + background-color: oklch(0% 0 0/ 0.4); + .modal-box { + translate: 0 0; + scale: 1; + opacity: 1; + } + } + @starting-style { + &:checked + .modal { + @apply invisible opacity-0; + } + } + } + + .modal-backdrop { + @apply col-start-1 row-start-1 grid self-stretch justify-self-stretch text-transparent; + z-index: -1; + + button { + @apply cursor-pointer; + } + } + + .modal-box { + @apply col-start-1 row-start-1 max-h-screen w-11/12 max-w-[32rem] bg-neutral-100 p-6; + transition: + translate 0.3s ease-out, + scale 0.3s ease-out, + opacity 0.2s ease-out 0.05s, + box-shadow 0.3s ease-out; + border-top-left-radius: var(--modal-tl, var(--radius-box)); + border-top-right-radius: var(--modal-tr, var(--radius-box)); + border-bottom-left-radius: var(--modal-bl, var(--radius-box)); + border-bottom-right-radius: var(--modal-br, var(--radius-box)); + scale: 95%; + opacity: 0; + box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px; + overflow-y: auto; + overscroll-behavior: contain; + } + + .modal-top { + @apply place-items-start; + + :where(.modal-box) { + @apply h-auto w-full max-w-none; + max-height: calc(100vh - 5em); + translate: 0 -100%; + scale: 1; + --modal-tl: 0; + --modal-tr: 0; + --modal-bl: var(--radius-box); + --modal-br: var(--radius-box); + } + } + + .modal-middle { + @apply place-items-center; + + :where(.modal-box) { + @apply h-auto w-11/12 max-w-[32rem]; + max-height: calc(100vh - 5em); + translate: 0 2%; + scale: 98%; + --modal-tl: var(--radius-box); + --modal-tr: var(--radius-box); + --modal-bl: var(--radius-box); + --modal-br: var(--radius-box); + } + } + + .modal-bottom { + @apply place-items-end; + + :where(.modal-box) { + @apply h-auto w-full max-w-none; + max-height: calc(100vh - 5em); + translate: 0 100%; + scale: 1; + --modal-tl: var(--radius-box); + --modal-tr: var(--radius-box); + --modal-bl: 0; + --modal-br: 0; + } + } + + .modal-start { + @apply place-items-start; + + :where(.modal-box) { + @apply h-screen max-h-none w-auto max-w-none; + translate: -100% 0; + scale: 1; + --modal-tl: 0; + --modal-tr: var(--radius-box); + --modal-bl: 0; + --modal-br: var(--radius-box); + } + } + + .modal-end { + @apply place-items-end; + + :where(.modal-box) { + @apply h-screen max-h-none w-auto max-w-none; + translate: 100% 0; + scale: 1; + --modal-tl: var(--radius-box); + --modal-tr: 0; + --modal-bl: var(--radius-box); + --modal-br: 0; + } + } +} diff --git a/src/lib/attachments/click-outside.svelte.ts b/src/lib/attachments/click-outside.svelte.ts new file mode 100644 index 0000000..409c81e --- /dev/null +++ b/src/lib/attachments/click-outside.svelte.ts @@ -0,0 +1,48 @@ +import type { Attachment } from 'svelte/attachments'; + +export function clickOutside(callback: () => void): Attachment { + return (node) => { + function handleClick(event: MouseEvent) { + if (window.getSelection()?.toString()) { + // Don't close if text is selected + return; + } + + // For dialog elements, check if click was on the backdrop + if (node instanceof HTMLDialogElement) { + const rect = node.getBoundingClientRect(); + const isInDialog = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!isInDialog) { + callback(); + return; + } + } + + // For non-dialog elements, use the standard contains check + if (!node.contains(event.target as Node) && !event.defaultPrevented) { + callback(); + } + } + + // For dialogs, listen on the element itself + if (node instanceof HTMLDialogElement) { + node.addEventListener('click', handleClick); + } else { + // For other elements, listen on the document + document.addEventListener('click', handleClick, true); + } + + return () => { + if (node instanceof HTMLDialogElement) { + node.removeEventListener('click', handleClick); + } else { + document.removeEventListener('click', handleClick, true); + } + }; + }; +} diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index c15b58e..102b362 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { api } from './_generated/api'; import { messageRoleValidator, providerValidator } from './schema'; -import { Id } from './_generated/dataModel'; +import { type Id } from './_generated/dataModel'; export const getAllFromConversation = query({ args: { diff --git a/src/lib/backend/convex/user_rules.ts b/src/lib/backend/convex/user_rules.ts index f8d4ba7..e32d0ae 100644 --- a/src/lib/backend/convex/user_rules.ts +++ b/src/lib/backend/convex/user_rules.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { internal } from './_generated/api'; import { ruleAttachValidator } from './schema'; -import { Doc } from './_generated/dataModel'; +import { type Doc } from './_generated/dataModel'; export const create = mutation({ args: { diff --git a/src/lib/components/ui/modal/index.ts b/src/lib/components/ui/modal/index.ts new file mode 100644 index 0000000..5885b85 --- /dev/null +++ b/src/lib/components/ui/modal/index.ts @@ -0,0 +1,3 @@ +import Modal from './modal.svelte'; + +export { Modal }; diff --git a/src/lib/components/ui/modal/modal.svelte b/src/lib/components/ui/modal/modal.svelte new file mode 100644 index 0000000..3e637af --- /dev/null +++ b/src/lib/components/ui/modal/modal.svelte @@ -0,0 +1,35 @@ + + + (open = false)}> + + diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte index b0185ed..24352c6 100644 --- a/src/lib/components/ui/textarea/textarea.svelte +++ b/src/lib/components/ui/textarea/textarea.svelte @@ -1,8 +1,14 @@ diff --git a/src/lib/components/ui/tooltip.svelte b/src/lib/components/ui/tooltip.svelte new file mode 100644 index 0000000..32da1d4 --- /dev/null +++ b/src/lib/components/ui/tooltip.svelte @@ -0,0 +1,71 @@ + + +{@render trigger(tooltip)} + +
+
+

{@render children()}

+
+ + diff --git a/src/lib/spells/textarea-autosize.svelte.ts b/src/lib/spells/textarea-autosize.svelte.ts new file mode 100644 index 0000000..4df4767 --- /dev/null +++ b/src/lib/spells/textarea-autosize.svelte.ts @@ -0,0 +1,182 @@ +import { useResizeObserver, watch, extract } from 'runed'; +import { onDestroy } from 'svelte'; +import type { Attachment } from 'svelte/attachments'; +import { on } from 'svelte/events'; + +export interface TextareaAutosizeOptions { + /** Function called when the textarea size changes. */ + onResize?: () => void; + /** + * Specify the style property that will be used to manipulate height. Can be `height | minHeight`. + * @default `height` + **/ + styleProp?: 'height' | 'minHeight'; + /** + * Maximum height of the textarea before enabling scrolling. + * @default `undefined` (no maximum) + */ + maxHeight?: number; +} + +export class TextareaAutosize { + #options: TextareaAutosizeOptions; + #resizeTimeout: number | null = null; + #hiddenTextarea: HTMLTextAreaElement | null = null; + + element: HTMLTextAreaElement | undefined; + #input = ''; + styleProp = $derived.by(() => extract(this.#options.styleProp, 'height')); + maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined)); + textareaHeight = $state(0); + textareaOldWidth = $state(0); + + constructor(options: TextareaAutosizeOptions = {}) { + this.#options = options; + + // Create hidden textarea for measurements + this.#createHiddenTextarea(); + + watch( + () => this.textareaHeight, + () => options?.onResize?.() + ); + + useResizeObserver( + () => this.element, + ([entry]) => { + if (!entry) return; + const { contentRect } = entry; + if (this.textareaOldWidth === contentRect.width) return; + + this.textareaOldWidth = contentRect.width; + this.triggerResize(); + } + ); + + onDestroy(() => { + // Clean up + if (this.#hiddenTextarea) { + this.#hiddenTextarea.remove(); + this.#hiddenTextarea = null; + } + + if (this.#resizeTimeout) { + window.cancelAnimationFrame(this.#resizeTimeout); + this.#resizeTimeout = null; + } + }); + } + + #createHiddenTextarea() { + // Create a hidden textarea that will be used for measurements + // This avoids layout shifts caused by manipulating the actual textarea + if (typeof window === 'undefined') return; + + this.#hiddenTextarea = document.createElement('textarea'); + const style = this.#hiddenTextarea.style; + + // Make it invisible but keep same text layout properties + style.visibility = 'hidden'; + style.position = 'absolute'; + style.overflow = 'hidden'; + style.height = '0'; + style.top = '0'; + style.left = '-9999px'; + + document.body.appendChild(this.#hiddenTextarea); + } + + #copyStyles() { + if (!this.element || !this.#hiddenTextarea) return; + + const computed = window.getComputedStyle(this.element); + + // Copy all the styles that affect text layout + const stylesToCopy = [ + 'box-sizing', + 'width', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'letter-spacing', + 'text-indent', + 'text-transform', + 'line-height', + 'word-spacing', + 'word-wrap', + 'word-break', + 'white-space', + ]; + + stylesToCopy.forEach((style) => { + this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style)); + }); + + // Ensure the width matches exactly + this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`; + } + + triggerResize = () => { + if (!this.element || !this.#hiddenTextarea) return; + + // Copy current styles and content to hidden textarea + this.#copyStyles(); + this.#hiddenTextarea.value = this.#input || ''; + + // Measure the hidden textarea + const scrollHeight = this.#hiddenTextarea.scrollHeight; + + // Apply the height, respecting maxHeight if set + let newHeight = scrollHeight; + if (this.maxHeight && newHeight > this.maxHeight) { + newHeight = this.maxHeight; + this.element.style.overflowY = 'auto'; + } else { + this.element.style.overflowY = 'hidden'; + } + + // Only update if height actually changed + if (this.textareaHeight !== newHeight) { + this.textareaHeight = newHeight; + this.element.style[this.styleProp] = `${newHeight}px`; + } + }; + + attachment: Attachment = (node) => { + this.element = node; + this.#input = node.value; + + // Detect programmatic changes + const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!; + Object.defineProperty(node, 'value', { + get: desc.get, + set: (v) => { + this.#input = v; + queueMicrotask(this.triggerResize); + desc.set?.call(node, v); + }, + }); + + queueMicrotask(this.triggerResize); + + const removeListener = on(node, 'input', () => { + this.#input = node.value; + this.triggerResize(); + }); + + return () => { + removeListener(); + this.#input = ''; + this.element = undefined; + }; + }; +} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 02aa4d9..9aa8486 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -14,6 +14,7 @@ import { goto } from '$app/navigation'; import { useCachedQuery } from '$lib/cache/cached-query.svelte.js'; import { api } from '$lib/backend/convex/_generated/api.js'; + import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; let { data, children } = $props(); @@ -45,6 +46,8 @@ const conversationsQuery = useCachedQuery(api.conversations.get, { session_token: session.current?.session.token ?? '', }); + + const _autosize = new TextareaAutosize(); @@ -106,6 +109,8 @@ }} bind:this={form} > + +