From dc0a92d963d4c456597a70e57c3a27a3fb807124 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:39:03 +0100 Subject: [PATCH] melty grid command --- README.md | 1 + package.json | 1 - pnpm-lock.yaml | 54 ----- src/lib/builders/grid-command.svelte.ts | 194 ++++++++++++++++++ src/lib/utils/array.ts | 104 ++++++++++ src/lib/utils/attribute.ts | 50 +++++ src/lib/utils/types.ts | 47 +++++ src/routes/chat/model-picker.svelte | 259 ++++++++++++------------ 8 files changed, 525 insertions(+), 185 deletions(-) create mode 100644 src/lib/builders/grid-command.svelte.ts create mode 100644 src/lib/utils/attribute.ts create mode 100644 src/lib/utils/types.ts diff --git a/README.md b/README.md index 8f77369..c200ade 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ TODO: add instructions - [x] kbd powered popover model picker - [x] autosize - [x] AbortController for message generation +- [ ] Per route msg persistance ### Extra diff --git a/package.json b/package.json index 35a9c56..76d712f 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,6 @@ "@fontsource-variable/nunito-sans": "^5.2.6", "@fontsource-variable/open-sans": "^5.2.6", "better-auth": "^1.2.9", - "bits-ui": "^2.8.0", "convex-helpers": "^0.1.94", "markdown-it-async": "^2.2.0", "openai": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29a7344..2c75d23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: better-auth: specifier: ^1.2.9 version: 1.2.9 - bits-ui: - specifier: ^2.8.0 - version: 2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1) convex-helpers: specifier: ^0.1.94 version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64) @@ -665,9 +662,6 @@ packages: '@iconify/utils@2.3.0': resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} - '@internationalized/date@3.8.2': - resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -912,9 +906,6 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 - '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - '@tailwindcss/node@4.1.10': resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==} @@ -1232,13 +1223,6 @@ packages: better-call@1.0.9: resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==} - bits-ui@2.8.0: - resolution: {integrity: sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA==} - engines: {node: '>=20', pnpm: '>=8.7.0'} - peerDependencies: - '@internationalized/date': ^3.8.1 - svelte: ^5.33.0 - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2397,12 +2381,6 @@ packages: peerDependencies: svelte: ^5.0.0 - svelte-toolbelt@0.9.2: - resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.30.2 - svelte@5.34.1: resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==} engines: {node: '>=18'} @@ -2410,9 +2388,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tabbable@6.2.0: - resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} - tailwind-merge@3.0.2: resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==} @@ -3086,10 +3061,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@internationalized/date@3.8.2': - dependencies: - '@swc/helpers': 0.5.17 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -3335,10 +3306,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc/helpers@0.5.17': - dependencies: - tslib: 2.8.1 - '@tailwindcss/node@4.1.10': dependencies: '@ampproject/remapping': 2.3.0 @@ -3684,18 +3651,6 @@ snapshots: set-cookie-parser: 2.7.1 uncrypto: 0.1.3 - bits-ui@2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1): - dependencies: - '@floating-ui/core': 1.7.1 - '@floating-ui/dom': 1.7.1 - '@internationalized/date': 3.8.2 - css.escape: 1.5.1 - esm-env: 1.2.2 - runed: 0.28.0(svelte@5.34.1) - svelte: 5.34.1 - svelte-toolbelt: 0.9.2(svelte@5.34.1) - tabbable: 6.2.0 - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4789,13 +4744,6 @@ snapshots: style-to-object: 1.0.9 svelte: 5.34.1 - svelte-toolbelt@0.9.2(svelte@5.34.1): - dependencies: - clsx: 2.1.1 - runed: 0.28.0(svelte@5.34.1) - style-to-object: 1.0.9 - svelte: 5.34.1 - svelte@5.34.1: dependencies: '@ampproject/remapping': 2.3.0 @@ -4815,8 +4763,6 @@ snapshots: symbol-tree@3.2.4: {} - tabbable@6.2.0: {} - tailwind-merge@3.0.2: {} tailwind-merge@3.3.1: {} diff --git a/src/lib/builders/grid-command.svelte.ts b/src/lib/builders/grid-command.svelte.ts new file mode 100644 index 0000000..cb6638c --- /dev/null +++ b/src/lib/builders/grid-command.svelte.ts @@ -0,0 +1,194 @@ +import { getNextMatrixItem, type Direction } from '$lib/utils/array'; +import { dataAttr } from '$lib/utils/attribute'; +import type { MaybeGetter } from 'melt'; +import { extract } from 'runed'; +import { tick } from 'svelte'; +import { createAttachmentKey } from 'svelte/attachments'; +import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements'; + +export type GridCommandProps = { + columns: MaybeGetter; + onSelect: (value: string) => void; +}; +export class GridCommand { + /* State */ + rootEl: HTMLElement | null = null; + highlighted = $state(); + inputValue = $state(''); + + /* Props */ + columns: number; + onSelect: (value: string) => void; + + constructor(props: GridCommandProps) { + this.columns = $derived(extract(props.columns)); + this.onSelect = props.onSelect; + } + + get root() { + return { + [createAttachmentKey()]: (node) => { + this.rootEl = node; + return () => { + if (this.rootEl !== node) return; + this.rootEl = null; + }; + }, + } as const satisfies HTMLAttributes; + } + + get group() { + return { + 'data-thom-grid-command-group': '', + } as const satisfies HTMLAttributes; + } + + get groupHeading() { + return { + 'data-thom-grid-command-group-heading': '', + } as const satisfies HTMLAttributes; + } + + get input() { + return { + value: this.inputValue, + oninput: async (e) => { + this.inputValue = e.currentTarget.value; + await tick(); + const items = this.getItems(); + const highlightedEl = items.find((item) => item.dataset.value === this.highlighted); + if (!highlightedEl) { + const firstItem = items[0]; + if (!firstItem) this.highlighted = undefined; + else this.highlighted = firstItem.dataset.value; + } + }, + onkeydown: (e) => { + const rows = this.getRows(); + if (rows.length === 0) return; + + const row = rows.findIndex((row) => { + return !!row.find((item) => { + return this.highlighted === item.dataset.value; + }); + }); + + if (row === -1) return; + const col = rows[row]!.findIndex((item) => this.highlighted === item.dataset.value); + + const dirMap: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + }; + const dir = dirMap[e.key]; + if (dir) { + const next = getNextMatrixItem(rows, row, col, dir); + if (next) { + this.highlighted = next.dataset.value; + this.scrollToHighlighted(); + } + return; + } + + if (e.key === 'Enter' && this.highlighted) { + e.preventDefault(); + this.onSelect(this.highlighted); + } + }, + } as const satisfies HTMLInputAttributes; + } + + scrollToHighlighted() { + const groups = this.getGroups(); + const items = groups.flatMap((group) => group); + const highlightedEl = items.find((item) => item.dataset.value === this.highlighted); + if (!highlightedEl) return; + + const nextGroupIdx = groups.findIndex((group) => group.includes(highlightedEl)); + const nextGroup = groups[nextGroupIdx]!; + const nextItemIdx = nextGroup.findIndex((item) => item === highlightedEl); + const nextItemRow = Math.floor(nextItemIdx / this.columns); + if (nextItemRow === 0) { + const nextGroupEl = Array.from( + this.rootEl!.querySelectorAll('[data-thom-grid-command-group]') + )[nextGroupIdx]; + const nextGroupHeadingEl = nextGroupEl?.querySelector( + '[data-thom-grid-command-group-heading]' + ); + if (nextGroupHeadingEl) { + nextGroupHeadingEl.scrollIntoView({ block: 'nearest' }); + } + } + highlightedEl.scrollIntoView({ block: 'nearest' }); + } + + getRows() { + const groups = this.getGroups(); + if (groups.length === 0) return []; + + // split groups into rows + const rows: Array = []; + + for (let i = 0; i < groups.length; i++) { + const items = [...groups[i]!]; + + let row: HTMLElement[] = []; + while (items.length > 0) { + const item = items.shift()!; + row.push(item); + if (row.length === this.columns) { + rows.push(row); + row = []; + } + } + if (row.length > 0) { + rows.push(row); + } + } + + return rows; + } + + getGroups() { + if (!this.rootEl) return []; + + const groups = Array.from(this.rootEl.querySelectorAll('[data-thom-grid-command-group]')); + + const result: Array = []; + + for (const group of groups) { + const groupItems = Array.from( + group.querySelectorAll('[data-thom-grid-command-item]') + ) as HTMLElement[]; + + result.push(groupItems); + } + + return result; + } + + getItems() { + return this.getRows().flatMap((row) => row); + } + + getItem(value: string) { + return { + 'data-thom-grid-command-item': '', + 'data-highlighted': dataAttr(value === this.highlighted), + 'data-value': dataAttr(value), + onmouseover: () => { + this.highlighted = value; + }, + onclick: () => { + this.onSelect(value); + }, + [createAttachmentKey()]: () => { + if (!this.highlighted) { + this.highlighted = value; + } + }, + } as const satisfies HTMLAttributes; + } +} diff --git a/src/lib/utils/array.ts b/src/lib/utils/array.ts index 47409fb..574f8f9 100644 --- a/src/lib/utils/array.ts +++ b/src/lib/utils/array.ts @@ -126,3 +126,107 @@ export function* iterate(array: T[]): Generator> { export function last(arr: T[]): T | undefined { return arr[arr.length - 1]; } + +/** + * Defines the possible directions for movement within the matrix. + */ +export type Direction = 'up' | 'down' | 'left' | 'right'; + +/** + * Retrieves the next item in a matrix based on a current position and a direction. + * This function is designed to work with "jagged" arrays (rows can have different lengths). + * + * @template T The type of items stored in the matrix. + * @param matrix The matrix (an array of arrays) where rows can have varying lengths. + * @param currentRow The 0-based index of the current row. + * @param currentCol The 0-based index of the current column. + * @param direction The direction to move ('up', 'down', 'left', 'right'). + * @returns The item at the next position, or `undefined` if the current position is invalid, + * the next position is out of bounds, or the matrix itself is invalid. + */ +export function getNextMatrixItem( + matrix: T[][], + currentRow: number, + currentCol: number, + direction: Direction +): T | undefined { + // --- 1. Input Validation: Matrix and Current Position --- + + // Ensure the matrix is a valid array and not empty + if (!matrix || !Array.isArray(matrix) || matrix.length === 0) { + return undefined; + } + + // Validate the current row index + if (currentRow < 0 || currentRow >= matrix.length) { + return undefined; + } + + // Get the current row array to validate the current column + const currentRowArray = matrix[currentRow]; + + // Ensure the current row is a valid array and validate current column index + if ( + !currentRowArray || + !Array.isArray(currentRowArray) || + currentCol < 0 || + currentCol >= currentRowArray.length + ) { + return undefined; + } + + // --- 2. Calculate Tentative Next Coordinates --- + + let nextRow = currentRow; + let nextCol = currentCol; // Start with the same column + + switch (direction) { + case 'up': + nextRow--; + break; + case 'down': + nextRow++; + break; + case 'left': + nextCol--; // Column changes for horizontal movement + break; + case 'right': + nextCol++; // Column changes for horizontal movement + break; + } + + // --- 3. Validate and Adjust Next Coordinates Against Matrix Bounds --- + + // Check if the calculated next row is within the matrix's vertical bounds + if (nextRow < 0 || nextRow >= matrix.length) { + return undefined; // Out of vertical bounds + } + + // Get the array for the target row. This is crucial for jagged arrays. + const nextRowArray = matrix[nextRow]; + + // Ensure the target row is a valid array before checking its length + if (!nextRowArray || !Array.isArray(nextRowArray)) { + return undefined; // The row itself is malformed or non-existent + } + + // --- NEW LOGIC: Adjust nextCol for vertical movements if it's out of bounds --- + if (direction === 'up' || direction === 'down') { + // If the tentative nextCol is beyond the length of the target row, + // clamp it to the last valid index of that row. + // If nextRowArray.length is 0, then nextRowArray.length - 1 is -1. + // Math.min(currentCol, -1) would result in -1, which will then correctly + // be caught by the next `if (nextCol < 0)` check. + nextCol = Math.min(nextCol, nextRowArray.length - 1); + } + + // Check if the calculated next column is within the target row's horizontal bounds + // This applies to ALL directions, including after potential clamping for up/down. + if (nextCol < 0 || nextCol >= nextRowArray.length) { + return undefined; // Out of horizontal bounds for the specific nextRow + } + + // --- 4. Return the Item --- + + return nextRowArray[nextCol]; +} diff --git a/src/lib/utils/attribute.ts b/src/lib/utils/attribute.ts new file mode 100644 index 0000000..823e66f --- /dev/null +++ b/src/lib/utils/attribute.ts @@ -0,0 +1,50 @@ +import type { ObjToString } from './types'; + +type DataReturn = T extends true ? '' : T extends false ? undefined : T; + +export function dataAttr(value: T): DataReturn { + return (value === true ? '' : value === false ? undefined : value) as DataReturn; +} + +type DisabledReturn = T extends true ? true : undefined; +export function disabledAttr(value?: V): DisabledReturn { + return (value === true ? true : undefined) as DisabledReturn; +} + +export function styleAttr>(value: T): ObjToString { + return Object.entries(value) + .map(([key, value]) => `${key}: ${value};`) + .join(' ') as ObjToString; +} + +/** + * Generate a unique, safe ID attribute from a string using hash-based approach + */ +export function idAttr(value: string): string { + if (!value || typeof value !== 'string') { + return 'id-empty'; + } + + // Simple but effective hash function (djb2 algorithm) + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) + hash + value.charCodeAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + + // Convert to positive number and then to base36 for compactness + const hashStr = Math.abs(hash).toString(36); + + // Create readable prefix from original string + let prefix = value + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .slice(0, 8); + + // Ensure prefix starts with a letter + if (!prefix || /^[0-9]/.test(prefix)) { + prefix = 'id' + prefix; + } + + return `${prefix}-${hashStr}`; +} diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts new file mode 100644 index 0000000..75d47fc --- /dev/null +++ b/src/lib/utils/types.ts @@ -0,0 +1,47 @@ +export type ValueOf = T[keyof T]; + +/* God forgive me */ +export type Expand = T extends infer O ? O : never; + +export type ExpandDeep = T extends object + ? T extends infer O + ? { [K in keyof O]: ExpandDeep } + : never + : T; + +type What< + T extends Record, + Sep extends string = '; ', + K extends string = '', +> = ExpandDeep<{ + [key in keyof T]: key extends string + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {} extends Omit + ? `${K}${key}: ${T[key]}` + : What, Sep, `${K}${key}: ${T[key]}${Sep}`> + : never; +}>; + +type UnionToParm = U extends unknown ? (k: U) => void : never; +type UnionToSect = UnionToParm extends (k: infer I) => void ? I : never; +type ExtractParm = F extends { (a: infer A): void } ? A : never; + +type SpliceOne = Exclude>; +type ExtractOne = ExtractParm>>; + +type ToTuple = ToTupleRec; +type ToTupleRec = + SpliceOne extends never + ? [ExtractOne, ...Rslt] + : ToTupleRec, [ExtractOne, ...Rslt]>; + +// @ts-expect-error - this is hacky, but it works +type FindWhat = ToTuple>[0]; + +export type ObjToString> = FindWhat>; + +export type Expect = T; +export type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; + +export type FalseIfUndefined = T extends undefined ? false : T; diff --git a/src/routes/chat/model-picker.svelte b/src/routes/chat/model-picker.svelte index d68df71..2ee41a2 100644 --- a/src/routes/chat/model-picker.svelte +++ b/src/routes/chat/model-picker.svelte @@ -1,35 +1,37 @@