This commit is contained in:
2025-09-05 01:32:06 +10:00
parent 65c52be4a1
commit bba8986c88
13 changed files with 773 additions and 133 deletions

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import clsx from 'clsx';
let {
width = '80px',
height = '30px',
setColWidth = () => {},
setRowHeight = () => {},
val,
active,
direction = 'col' // New prop: 'col' for right-side drag, 'row' for bottom-side
}: {
width?: string;
height?: string;
setColWidth?: (width: string) => void;
setRowHeight?: (height: string) => void;
val: string;
active: boolean;
direction?: 'col' | 'row';
} = $props();
// --- Drag Logic ---
const handleMouseDown = (startEvent: MouseEvent) => {
// Prevent text selection during drag
startEvent.preventDefault();
const target = startEvent.currentTarget as HTMLElement;
const parent = target.parentElement!;
// Store the initial position and size
const startX = startEvent.clientX;
const startY = startEvent.clientY;
const startWidth = parent.offsetWidth;
const startHeight = parent.offsetHeight;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (direction === 'col') {
const dx = moveEvent.clientX - startX;
// Enforce a minimum width of 40px
setColWidth(`${Math.max(40, startWidth + dx)}px`);
} else {
const dy = moveEvent.clientY - startY;
// Enforce a minimum height of 20px
setRowHeight(`${Math.max(30, startHeight + dy)}px`);
}
};
const handleMouseUp = () => {
// Cleanup: remove the global listeners
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
// Add global listeners to track mouse movement anywhere on the page
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
</script>
<div
style:width
style:height
class={clsx('placeholder group relative bg-background p-1 dark:bg-input/30', { active })}
>
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
{val}
</span>
<div
role="separator"
aria-label="Resize handle"
onmousedown={handleMouseDown}
class={clsx('resizer', {
'resizer-col': direction === 'col',
'resizer-row': direction === 'row'
})}
/>
</div>
<style>
.placeholder {
font-size: 14px;
border: 1px solid var(--input);
overflow: hidden;
}
.active {
border: 1px solid var(--color-primary);
background-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
}
/* --- Resizer Styles --- */
.resizer {
position: absolute;
/* Make it easier to grab */
z-index: 10;
/* Subtle visual cue, becomes more visible on hover */
background-color: transparent;
transition: background-color 0.2s ease-in-out;
}
/* Style for vertical (column) resizing */
.resizer-col {
cursor: col-resize;
top: 0;
right: 0;
width: 8px; /* Larger grab area */
height: 100%;
}
/* Style for horizontal (row) resizing */
.resizer-row {
cursor: row-resize;
bottom: 0;
left: 0;
height: 8px; /* Larger grab area */
width: 100%;
}
/* Make the handle visible when hovering over the component */
.group:hover > .resizer {
background-color: var(--color-primary);
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import clsx from 'clsx';
let {
width = '80px',
height = '30px',
raw_val = $bindable(''),
val = undefined,
onmousedown = () => {},
startediting = () => {},
stopediting = () => {},
active = false,
editing = false
}: {
width?: string;
height?: string;
raw_val?: string;
val?: number | string | undefined;
onmousedown?: (e: MouseEvent) => void;
startediting?: () => void;
stopediting?: () => void;
active?: boolean;
editing?: boolean;
} = $props();
// focus the first focusable descendant (the inner <input>)
function autofocusWithin(node: HTMLElement) {
queueMicrotask(() => {
const el = node.querySelector('input') as HTMLInputElement | null;
if (el !== null) {
el.value = raw_val;
el.focus();
}
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === 'NumpadEnter' || e.key == 'Escape') {
e.preventDefault(); // avoid form submit/line break
const el = (e.currentTarget as HTMLElement).querySelector('input') as HTMLInputElement | null;
el?.blur(); // triggers on:blur below
}
}
</script>
{#if editing}
<div use:autofocusWithin onkeydown={handleKeydown}>
<Input
style="width: {width}; height: {height}"
class="relative rounded-none p-1
!transition-none delay-0 duration-0
focus:z-50"
onblur={(e) => {
raw_val = (e.target as HTMLInputElement).value;
stopediting();
}}
/>
</div>
{:else}
<div
ondblclick={startediting}
{onmousedown}
style:width
style:height
class={clsx('placeholder bg-background p-1 dark:bg-input/30', { active, 'z-50': active })}
>
{#if raw_val !== '' || val !== ''}
<span class="pointer-events-none select-none">
{#if val !== undefined}
{val}
{:else}
{raw_val}
{/if}
</span>
{/if}
</div>
{/if}
<style>
.placeholder {
border: 1px solid var(--input);
overflow: hidden;
}
.active {
border: 1px solid var(--color-primary);
outline: 1px solid var(--color-primary);
}
</style>

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import Cell from '$lib/components/grid/cell.svelte';
import { onDestroy, onMount } from 'svelte';
import CellHeader from './cell-header.svelte';
import { fromGridRef, toColLetter, toGridRef, type CellData, type CellValue } from './utils';
let {
socket
}: {
socket: WebSocket;
} = $props();
function splitErrorString(errorString: string) {
// Remove the "ERR " prefix.
const content = errorString.substring(4);
// Find the index of the first colon.
const colonIndex = content.indexOf(':');
// If no colon is found, return the whole content as the first element.
if (colonIndex === -1) {
return [content.trim(), ''];
}
// Extract the part before the colon (the error type).
const errorType = content.substring(0, colonIndex).trim();
// Extract the part after the colon (the error message).
const errorMessage = content.substring(colonIndex + 1).trim();
return [errorType, errorMessage];
}
socket.onmessage = (msg: MessageEvent) => {
const input = msg.data.toString().trim();
if (input.startsWith('ERR')) {
let split = splitErrorString(input);
toast.error(split[0], {
description: split[1]
});
return;
}
let cellRef: string | undefined;
let evalStr: string | undefined;
// Case 1: "Cell D4 = Integer(4)"
let match = input.match(/^Cell\s+([A-Z]+\d+)\s*=\s*(.+)$/);
if (match) {
[, cellRef, evalStr] = match;
}
// Case 2: "D6 String("hello")" or "E9 Double(4.0)"
if (!match) {
match = input.match(/^([A-Z]+\d+)\s+(.+)$/);
if (match) {
[, cellRef, evalStr] = match;
}
}
if (!cellRef || !evalStr) {
console.warn('Unrecognized message:', input);
return;
}
console.log(`Cell: ${cellRef}`);
console.log(`Eval: ${evalStr}`);
let [i, j] = fromGridRef(cellRef);
// Parse eval types
if (evalStr.startsWith('Integer(')) {
const num = parseInt(evalStr.match(/^Integer\(([-\d]+)\)$/)?.[1] ?? 'NaN', 10);
console.log(`Parsed integer:`, num);
setCellVal(i, j, num);
} else if (evalStr.startsWith('Double(')) {
const num = parseFloat(evalStr.match(/^Double\(([-\d.]+)\)$/)?.[1] ?? 'NaN');
console.log(`Parsed double:`, num);
setCellVal(i, j, num);
} else if (evalStr.startsWith('String(')) {
const str = evalStr.match(/^String\("(.+)"\)$/)?.[1];
console.log(`Parsed string:`, str);
setCellVal(i, j, str);
}
};
let rows = 50;
let cols = 50;
let default_row_height = '30px';
let default_col_width = '60px';
// Only store touched cells
let grid_vals: Record<string, CellData> = $state({});
let row_heights: Record<number, string> = $state({});
let col_widths: Record<number, string> = $state({});
function getRowHeight(row: number) {
return row_heights[row] ?? default_row_height;
}
function getColWidth(col: number) {
return col_widths[col] ?? default_col_width;
}
function setRowHeight(row: number, height: string) {
if (height === default_row_height) {
delete row_heights[row];
} else {
row_heights[row] = height;
}
}
function setColWidth(col: number, width: string) {
if (width === default_col_width) {
delete col_widths[col];
} else {
col_widths[col] = width;
}
}
let active_cell: [number, number] | null = $state(null);
let editing_cell: [number, number] | null = $state(null);
function startEditing(i: number, j: number) {
active_cell = [i, j];
editing_cell = [i, j];
}
function stopEditing() {
editing_cell = null;
}
const key = (i: number, j: number) => `${i}:${j}`;
const getCell = (i: number, j: number) => grid_vals[key(i, j)] ?? undefined;
const getCellRaw = (i: number, j: number) => getCell(i, j)?.raw_val ?? '';
const setCellRaw = (i: number, j: number, val: string) => {
if (grid_vals[key(i, j)] === undefined) {
grid_vals[key(i, j)] = {
raw_val: val,
val: undefined
};
} else {
grid_vals[key(i, j)].raw_val = val;
}
};
const getCellVal = (i: number, j: number) => getCell(i, j)?.val ?? undefined;
const setCellVal = (i: number, j: number, val: CellValue) => {
if (grid_vals[key(i, j)] === undefined) {
console.warn('Cell raw value was undefined but recieved eval.');
} else {
grid_vals[key(i, j)].val = val;
}
};
const setCell = (i: number, j: number, v: string | undefined) => {
// ignore “no value” so we dont create keys on mount
if (v == null || v === '') delete grid_vals[key(i, j)];
else {
setCellRaw(i, j, v);
console.log(i, j);
socket.send(`set ${toGridRef(i, j)} ${v}`);
}
};
// $effect(() => {
// $inspect(grid_vals);
// });
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
if (editing_cell) {
// Get the actual input element that's being edited
const el = document.querySelector<HTMLInputElement>('input:focus');
const currentInputValue = el?.value ?? '';
// ONLY treat this as a reference insert if it's a formula
if (currentInputValue.trim().startsWith('=')) {
// Prevent the input from losing focus
e.preventDefault();
// --- This is the same reference-inserting logic as before ---
const ref = toGridRef(i, j);
if (el) {
const { selectionStart, selectionEnd } = el;
const before = el.value.slice(0, selectionStart ?? 0);
const after = el.value.slice(selectionEnd ?? 0);
el.value = before + ref + after;
const newPos = (selectionStart ?? 0) + ref.length;
el.setSelectionRange(newPos, newPos);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
}
return;
}
}
// We are not editing, so this is a normal cell selection OR this is not a formula
active_cell = [i, j];
}
onMount(() => {
const handler = (e: MouseEvent) => {
// optional: check if click target is outside grid container
if (!(e.target as HTMLElement).closest('.grid-wrapper')) {
active_cell = null;
}
};
window.addEventListener('click', handler);
onDestroy(() => window.removeEventListener('click', handler));
});
</script>
<div class="grid-wrapper">
<div class="flex w-fit">
<CellHeader height={default_row_height} width={default_col_width} val="" active={false} />
{#each Array(cols) as _, j}
<CellHeader
height={default_row_height}
width={getColWidth(j)}
setColWidth={(width) => setColWidth(j, width)}
direction="col"
val={toColLetter(j + 1)}
active={active_cell !== null && active_cell[1] === j}
/>
{/each}
</div>
{#each Array(rows) as _, i}
<div class="flex w-fit">
<CellHeader
direction="row"
width={default_col_width}
height={getRowHeight(i)}
setRowHeight={(height) => setRowHeight(i, height)}
val={(i + 1).toString()}
active={active_cell !== null && active_cell[0] === i}
/>
{#each Array(cols) as _, j}
<Cell
height={getRowHeight(i)}
width={getColWidth(j)}
editing={editing_cell?.[0] === i && editing_cell?.[1] === j}
startediting={() => startEditing(i, j)}
stopediting={stopEditing}
onmousedown={(e) => {
handleCellInteraction(i, j, e);
}}
bind:raw_val={() => getCellRaw(i, j), (v) => setCell(i, j, v)}
val={getCellVal(i, j)}
active={active_cell !== null && active_cell[0] === i && active_cell[1] === j}
/>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,39 @@
export function fromGridRef(ref: string): [number, number] {
const match = ref.match(/^([A-Z]+)([0-9]+)$/i);
if (!match) throw new Error('Invalid reference');
const [, letters, rowStr] = match;
let col = 0;
for (let i = 0; i < letters.length; i++) {
col = col * 26 + (letters.charCodeAt(i) - 64); // 'A' = 65 → 1
}
const row = parseInt(rowStr, 10);
return [row - 1, col - 1];
}
export function toColLetter(col: number): string {
let result = '';
let n = col;
while (n > 0) {
const rem = (n - 1) % 26;
result = String.fromCharCode(65 + rem) + result; // 65 = 'A'
n = Math.floor((n - 1) / 26);
}
return result;
}
export function toGridRef(row: number, col: number): string {
row++;
col++;
return toColLetter(col) + row.toString();
}
export type CellValue = number | string | undefined;
export interface CellData {
raw_val: string;
val: CellValue;
}

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>