Merge remote-tracking branch 'origin'

This commit is contained in:
2025-09-12 01:46:41 +10:00
11 changed files with 520 additions and 168 deletions

View File

@@ -1,14 +1,16 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cell::CellRef; use crate::{
use crate::common::LeadErr; cell::CellRef,
use crate::common::LeadErrCode; common::{LeadErr, LeadErrCode, Literal},
use crate::common::Literal; evaluator::utils::*,
use crate::grid::Grid; grid::Grid,
use crate::parser::*; parser::*,
use std::collections::HashSet; };
use std::f64;
use std::fmt; use std::{collections::HashSet, f64, fmt};
mod utils;
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -266,37 +268,6 @@ fn eval_avg(
} }
} }
fn eval_single_arg_numeric(
args: &Vec<Expr>,
precs: &mut HashSet<CellRef>,
grid: Option<&Grid>,
func: fn(f64) -> f64,
func_name: String,
) -> Result<Eval, LeadErr> {
if args.len() != 1 {
return Err(LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a single argument."),
code: LeadErrCode::Invalid,
});
}
let err = LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a numeric argument."),
code: LeadErrCode::TypeErr,
};
match evaluate_expr(&args[0], precs, grid)? {
Eval::Literal(Literal::Number(num)) => Ok(Eval::Literal(Literal::Number(func(num)))),
Eval::CellRef { eval, .. } => match *eval {
Eval::Literal(Literal::Number(n)) => Ok(Eval::Literal(Literal::Number(func(n)))),
_ => Err(err),
},
_ => Err(err),
}
}
fn eval_const(args: &Vec<Expr>, value: Eval) -> Result<Eval, LeadErr> { fn eval_const(args: &Vec<Expr>, value: Eval) -> Result<Eval, LeadErr> {
if args.len() != 0 { if args.len() != 0 {
return Err(LeadErr { return Err(LeadErr {

View File

@@ -0,0 +1,76 @@
use std::collections::HashSet;
use crate::{
cell::CellRef,
common::{LeadErr, LeadErrCode, Literal},
evaluator::{Eval, evaluate_expr},
grid::Grid,
parser::Expr,
};
pub fn eval_single_arg_numeric(
args: &Vec<Expr>,
precs: &mut HashSet<CellRef>,
grid: Option<&Grid>,
func: fn(f64) -> f64,
func_name: String,
) -> Result<Eval, LeadErr> {
if args.len() != 1 {
return Err(LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a single argument."),
code: LeadErrCode::Invalid,
});
}
let err = LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a numeric argument."),
code: LeadErrCode::TypeErr,
};
match evaluate_expr(&args[0], precs, grid)? {
Eval::Literal(Literal::Number(num)) => Ok(Eval::Literal(Literal::Number(func(num)))),
Eval::CellRef { eval, .. } => match *eval {
Eval::Literal(Literal::Number(n)) => Ok(Eval::Literal(Literal::Number(func(n)))),
_ => Err(err),
},
_ => Err(err),
}
}
pub fn eval_n_arg_numeric(
n: usize,
args: &Vec<Expr>,
precs: &mut HashSet<CellRef>,
grid: Option<&Grid>,
func: fn(Vec<f64>) -> f64,
func_name: String,
) -> Result<Eval, LeadErr> {
if args.len() != n {
return Err(LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires {n} argument(s)."),
code: LeadErrCode::Invalid,
});
}
let err = LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires numeric argument(s)."),
code: LeadErrCode::TypeErr,
};
let mut numbers = Vec::with_capacity(n);
for arg in args {
match evaluate_expr(arg, precs, grid)? {
Eval::Literal(Literal::Number(num)) => numbers.push(num),
Eval::CellRef { eval, .. } => match *eval {
Eval::Literal(Literal::Number(num)) => numbers.push(num),
_ => return Err(err.clone()),
},
_ => return Err(err.clone()),
}
}
Ok(Eval::Literal(Literal::Number(func(numbers))))
}

View File

@@ -1,38 +0,0 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -3,32 +3,31 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { getErrDesc, getErrTitle, getEvalLiteral, isErr } from './utils'; import { getErrDesc, getErrTitle, getEvalLiteral, isErr } from './utils';
import * as HoverCard from '$lib/components/ui/hover-card/index.js'; import * as HoverCard from '$lib/components/ui/hover-card/index.js';
import type { CellT } from './messages'; import { Position, type Grid } from './grid.svelte.ts';
let { let {
cla = '', cla = '',
width = '80px', pos,
height = '30px',
cell = $bindable(undefined),
onmousedown = () => {}, onmousedown = () => {},
startediting = () => {}, grid
stopediting = () => {},
active = false,
editing = false,
externalediting = false
}: { }: {
cla?: string; cla?: string;
width?: string; width?: string;
height?: string; height?: string;
cell?: CellT; grid: Grid;
pos: Position;
onmousedown?: (e: MouseEvent) => void; onmousedown?: (e: MouseEvent) => void;
startediting?: () => void;
stopediting?: () => void;
active?: boolean;
editing?: boolean;
externalediting?: boolean;
} = $props(); } = $props();
let cell = $derived(grid.getCell(pos));
let active = $derived(grid.isActive(pos));
let primaryactive = $derived(grid.isPrimaryActive(pos));
let editing = $derived(grid.isEditing(pos));
let externalediting = $derived(grid.isExternalEditing(pos));
let width = $derived(grid.getColWidth(pos.col));
let height = $derived(grid.getRowHeight(pos.row));
let showPreview = $derived(getPreview() !== '');
// focus the first focusable descendant (the inner <input>) // focus the first focusable descendant (the inner <input>)
function autofocusWithin(node: HTMLElement) { function autofocusWithin(node: HTMLElement) {
queueMicrotask(() => { queueMicrotask(() => {
@@ -47,22 +46,21 @@
el?.blur(); // triggers on:blur below el?.blur(); // triggers on:blur below
} else if (e.key == 'Escape') { } else if (e.key == 'Escape') {
e.preventDefault(); e.preventDefault();
stopediting(); grid.stopEditing(pos);
grid.resetCellTemp(pos);
} }
} }
function getPreview() { function getPreview() {
return !isErr(cell?.temp_eval) ? getEvalLiteral(cell?.temp_eval) : ''; return !isErr(cell?.temp_eval) ? getEvalLiteral(cell?.temp_eval) : '';
} }
let showPreview = $derived(getPreview() !== '');
</script> </script>
{#if editing} {#if editing}
<div class="relative inline-block"> <div class="relative inline-block">
{#if showPreview} {#if showPreview}
<h3 <h3
class="bubble pointer-events-none absolute -top-[6px] -left-1 z-[500] -translate-y-full text-sm font-semibold tracking-tight text-foreground select-none" class="bubble pointer-events-none absolute top-1/2 left-[2px] z-[500] -translate-y-[calc(50%+2.5em)] text-sm font-semibold tracking-tight text-foreground select-none"
role="tooltip" role="tooltip"
> >
{getPreview()} {getPreview()}
@@ -74,16 +72,16 @@
style="width: {width}; height: {height}" style="width: {width}; height: {height}"
class="relative rounded-none p-1 !transition-none delay-0 duration-0 class="relative rounded-none p-1 !transition-none delay-0 duration-0
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none" focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
bind:value={ bind:value={() => cell?.temp_raw ?? '', (v) => grid.setCellTemp(pos, v)}
() => cell?.temp_raw ?? '', onblur={() => {
(v) => (cell = { eval: cell?.eval, raw: cell?.raw ?? '', temp_raw: v }) grid.stopEditing(cell?.pos);
} grid.setCell(cell?.pos);
onblur={stopediting} }}
/> />
</div> </div>
</div> </div>
{:else if cell && isErr(cell.eval)} {:else if cell && isErr(cell.eval)}
<HoverCard.Root openDelay={500} closeDelay={500}> <HoverCard.Root openDelay={500} closeDelay={100}>
<HoverCard.Trigger> <HoverCard.Trigger>
{@render InnerCell()} {@render InnerCell()}
</HoverCard.Trigger> </HoverCard.Trigger>
@@ -100,15 +98,36 @@
{#snippet InnerCell()} {#snippet InnerCell()}
<div <div
ondblclick={startediting} ondblclick={() => grid.startEditing(pos)}
{onmousedown} {onmousedown}
data-row={pos.row}
data-col={pos.col}
ondragstart={(e) => e.preventDefault()}
style:width style:width
style:height style:height
class={clsx('placeholder bg-background p-1', { active }, cla)} class={clsx(
'placeholder bg-background p-1',
{
primaryactive,
active,
'active-top': grid.isActiveTop(pos),
'active-bottom': grid.isActiveBottom(pos),
'active-right': grid.isActiveRight(pos),
'active-left': grid.isActiveLeft(pos),
'only-active': grid.isActive(pos) && grid.isSingleActive()
},
cla
)}
> >
{#if cell && (cell.raw !== '' || getEvalLiteral(cell.eval) !== '')} {#if cell && (cell.raw !== '' || getEvalLiteral(cell.eval) !== '')}
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.eval) })}> <span
{#if cell.eval && !externalediting} class={clsx('pointer-events-none select-none', {
err: isErr(cell.eval)
})}
>
{#if externalediting}
{cell.temp_raw}
{:else if cell.eval}
{getEvalLiteral(cell.eval)} {getEvalLiteral(cell.eval)}
{:else} {:else}
{cell.raw} {cell.raw}
@@ -126,10 +145,38 @@
text-overflow: clip; text-overflow: clip;
} }
.primaryactive {
z-index: 30 !important;
border: 1px solid var(--color-primary) !important;
outline: 1px solid var(--color-primary);
}
.active { .active {
z-index: 20; z-index: 20;
border: 1px solid var(--color-primary); background-color: color-mix(in oklab, var(--color-primary) 20%, var(--color-background) 80%);
outline: 1px solid var(--color-primary); border: 1px solid color-mix(in oklab, var(--input) 100%, var(--color-foreground) 5%);
/* outline: 1px solid var(--color-primary); */
}
.only-active {
background-color: transparent !important;
}
/* Borders for edges */
.active-top {
border-top: 1px solid var(--color-primary);
}
.active-bottom {
border-bottom: 1px solid var(--color-primary);
}
.active-left {
border-left: 1px solid var(--color-primary);
}
.active-right {
border-right: 1px solid var(--color-primary);
} }
.active:has(.err), .active:has(.err),

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Omega } from '@lucide/svelte'; import { EllipsisVertical, Omega } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert/index.js';
import Cell from '$lib/components/grid/cell.svelte'; import Cell from '$lib/components/grid/cell.svelte';
import { onMount } from 'svelte';
import CellHeader from './cell-header.svelte'; import CellHeader from './cell-header.svelte';
import { colToStr, refToStr } from './utils'; import { colToStr, refToStr } from './utils';
import clsx from 'clsx'; import clsx from 'clsx';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import type { LeadMsg } from './messages'; import type { LeadMsg } from './messages';
import { Grid, Position } from './grid.svelte.ts'; import { Grid, Position } from './grid.svelte.ts';
import { onDestroy, onMount } from 'svelte';
let { let {
socket, socket,
@@ -31,14 +32,16 @@
grid.handle_msg(res); grid.handle_msg(res);
}; };
const grid = new Grid(socket); const grid = $state(new Grid(socket));
let rows = 10; let rows = 100;
let cols = 10; let cols = 50;
function handleCellInteraction(i: number, j: number, e: MouseEvent) { let dragging = $state(false);
function handleCellMouseDown(i: number, j: number, e: MouseEvent) {
let pos = new Position(i, j); let pos = new Position(i, j);
if (grid.isEditing(pos)) { if (grid.anyIsEditing()) {
// Get the actual input element that's being edited // Get the actual input element that's being edited
const el = document.querySelector<HTMLInputElement>('input:focus'); const el = document.querySelector<HTMLInputElement>('input:focus');
const currentInputValue = el?.value ?? ''; const currentInputValue = el?.value ?? '';
@@ -66,35 +69,92 @@
} }
// We are not editing, so this is a normal cell selection OR this is not a formula // We are not editing, so this is a normal cell selection OR this is not a formula
grid.setActive(pos); grid.setActive(pos, pos);
dragging = true;
} }
onMount(() => { onMount(() => {
// const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
// optional: check if click target is outside grid container // optional: check if click target is outside grid container
// if (!(e.target as HTMLElement).closest('.grid-wrapper')) { if (!(e.target as HTMLElement).closest('.grid-wrapper')) {
// active_cell = null; grid.stopEditingActive();
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!dragging) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (el && el instanceof HTMLElement && el.dataset.row && el.dataset.col) {
const row = parseInt(el.dataset.row, 10);
const col = parseInt(el.dataset.col, 10);
grid.setActive(grid.primary_active, new Position(row, col));
}
};
const handleMouseUp = (e: MouseEvent) => {
dragging = false; // stop tracking
//
// const el = document.elementFromPoint(e.clientX, e.clientY);
//
// if (el && el instanceof HTMLElement && el.dataset.row && el.dataset.col) {
// const row = parseInt(el.dataset.row, 10);
// const col = parseInt(el.dataset.col, 10);
//
// // expand selection as you drag
// let pos = new Position(row, col);
//
// if (grid.isActive(pos) && grid.isEditing(pos)) return;
//
// grid.stopAnyEditing();
// } // }
// }; };
// window.addEventListener('click', handler);
// onDestroy(() => window.removeEventListener('click', handler)); window.addEventListener('click', handler);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
onDestroy(() => {
window.removeEventListener('click', handler);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
});
}); });
</script> </script>
<div class="relative mb-5 ml-5 flex items-center gap-[5px]"> <div class="relative mb-5 ml-5 flex items-center gap-[5px]">
<Alert.Root
class={clsx(
'flex h-9 w-fit min-w-[80px] rounded-md border border-input bg-transparent px-2 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'flex items-center justify-center aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40'
)}
>
{grid.getActiveRangeStr()}
</Alert.Root>
<EllipsisVertical class="text-muted-foreground" size="20px" />
<div <div
class="relative" class="relative"
onkeydown={(e: KeyboardEvent) => { onkeydown={(e: KeyboardEvent) => {
const target = e.currentTarget as HTMLElement;
const input = target.querySelector('input') as HTMLInputElement | null;
if (e.key === 'Enter' || e.key === 'NumpadEnter') { if (e.key === 'Enter' || e.key === 'NumpadEnter') {
e.preventDefault(); // avoid form submit/line break e.preventDefault(); // avoid form submit/line break
const el = (e.currentTarget as HTMLElement).querySelector(
'input' grid.stopExternalEdit(grid.getActivePos());
) as HTMLInputElement | null; grid.setCell(grid.getActivePos());
el?.blur(); // triggers on:blur below input?.blur();
} else if (e.key == 'Escape') { } else if (e.key == 'Escape') {
e.preventDefault(); e.preventDefault();
grid.stopEditingActive(); grid.stopExternalEdit(grid.getActivePos());
grid.resetCellTemp(grid.getActivePos());
input?.blur();
} }
}} }}
> >
@@ -103,24 +163,22 @@
class="absolute top-1/2 left-2 z-10 -translate-y-1/2 text-muted-foreground" class="absolute top-1/2 left-2 z-10 -translate-y-1/2 text-muted-foreground"
/> />
<Input <Input
onmousedown={() => grid.setExternalEdit(grid.getActivePos())} disabled={grid.getActivePos() === null}
onblur={() => grid.setCell(grid.getActivePos())} onmousedown={() => grid.startExternalEdit(grid.getActivePos())}
onblur={() => grid.stopExternalEdit(grid.getActivePos())}
bind:value={ bind:value={
() => grid.getActiveCell()?.temp_raw ?? '', () => grid.getActiveCell()?.temp_raw ?? '',
(v) => { (v) => {
grid.setCellTemp(grid.getActivePos(), v); grid.setCellTemp(grid.getActivePos(), v);
} }
} }
class="relative w-[200px] pl-9" class="relative w-fit min-w-[300px] pl-9"
></Input> ></Input>
</div> </div>
</div> </div>
<div <div
class={clsx( class={clsx(' grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
' grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto overflow-visible',
className
)}
> >
<div class="sticky top-0 flex w-fit" style="z-index: {rows + 70}"> <div class="sticky top-0 flex w-fit" style="z-index: {rows + 70}">
<div class="sticky top-0 left-0" style="z-index: {rows + 70}"> <div class="sticky top-0 left-0" style="z-index: {rows + 70}">
@@ -141,7 +199,10 @@
setColWidth={(width) => grid.setColWidth(j, width)} setColWidth={(width) => grid.setColWidth(j, width)}
direction="col" direction="col"
val={colToStr(j)} val={colToStr(j)}
active={grid.getActivePos() !== null && grid.getActivePos()?.col === j} active={grid.primary_active !== null &&
grid.secondary_active !== null &&
j >= Math.min(grid.primary_active.col, grid.secondary_active.col) &&
j <= Math.max(grid.primary_active.col, grid.secondary_active.col)}
/> />
{/each} {/each}
</div> </div>
@@ -154,25 +215,21 @@
width={grid.getDefaultColWidth()} width={grid.getDefaultColWidth()}
setRowHeight={(height) => grid.setRowHeight(i, height)} setRowHeight={(height) => grid.setRowHeight(i, height)}
val={(i + 1).toString()} val={(i + 1).toString()}
active={grid.getActivePos() !== null && grid.getActivePos()?.row === i} active={grid.primary_active !== null &&
grid.secondary_active !== null &&
i >= Math.min(grid.primary_active.row, grid.secondary_active.row) &&
i <= Math.max(grid.primary_active.row, grid.secondary_active.row)}
/> />
</div> </div>
{#each Array(cols) as _, j} {#each Array(cols) as _, j}
<Cell <Cell
{grid}
pos={new Position(i, j)}
height={grid.getRowHeight(i)} height={grid.getRowHeight(i)}
width={grid.getColWidth(j)} width={grid.getColWidth(j)}
editing={grid.isEditing(new Position(i, j))}
externalediting={grid.isExternalEditing(new Position(i, j))}
startediting={() => grid.startEditing(new Position(i, j))}
stopediting={() => grid.stopEditing(new Position(i, j))}
onmousedown={(e) => { onmousedown={(e) => {
handleCellInteraction(i, j, e); handleCellMouseDown(i, j, e);
}} }}
bind:cell={
() => grid.getCell(new Position(i, j)),
(v) => grid.setCellTemp(new Position(i, j), v?.temp_raw)
}
active={grid.isActive(new Position(i, j))}
/> />
{/each} {/each}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import type { CellRef, CellT, Eval, LeadMsg } from './messages'; import type { CellRef, CellT, Eval, LeadMsg } from './messages';
import { refToStr } from './utils';
class Position { class Position {
public row: number; public row: number;
@@ -22,6 +23,10 @@ class Position {
return { row: this.row, col: this.col }; return { row: this.row, col: this.col };
} }
public str(): string {
return refToStr(this.row, this.col);
}
public equals(other: CellRef | null | undefined): boolean { public equals(other: CellRef | null | undefined): boolean {
return !!other && this.row === other.row && this.col === other.col; return !!other && this.row === other.row && this.col === other.col;
} }
@@ -38,7 +43,8 @@ class Grid {
col_widths: Record<number, string> = $state({}); col_widths: Record<number, string> = $state({});
default_row_height: string; default_row_height: string;
default_col_width: string; default_col_width: string;
active_cell: Position | null = $state(null); primary_active: Position | null = $state(null);
secondary_active: Position | null = $state(null);
editing_cell: Position | null = $state(null); editing_cell: Position | null = $state(null);
external_editing_cell: Position | null = $state(null); external_editing_cell: Position | null = $state(null);
editing_preview: [Eval, boolean] | null = $state(null); // [Eval, dirty] editing_preview: [Eval, boolean] | null = $state(null); // [Eval, dirty]
@@ -53,8 +59,8 @@ class Grid {
return this.data[pos.key()]; return this.data[pos.key()];
} }
public setCell(pos: Position | null) { public setCell(pos: Position | null | undefined) {
if (pos === null) return; if (pos === null || pos === undefined) return;
let data = this.data[pos.key()]; let data = this.data[pos.key()];
if (data === undefined) return; if (data === undefined) return;
@@ -83,6 +89,7 @@ class Grid {
this.data[pos.key()] = { this.data[pos.key()] = {
raw: x?.raw ?? '', raw: x?.raw ?? '',
temp_raw: raw, temp_raw: raw,
pos: pos,
eval: x?.eval ?? undefined, eval: x?.eval ?? undefined,
temp_eval: x?.temp_eval ?? undefined temp_eval: x?.temp_eval ?? undefined
}; };
@@ -90,6 +97,20 @@ class Grid {
this.quickEval(pos, raw); this.quickEval(pos, raw);
} }
public resetCellTemp(pos: Position | null | undefined) {
if (!pos) return;
let x = this.data[pos.key()];
this.data[pos.key()] = {
raw: x?.raw ?? '',
pos: pos,
temp_raw: x?.raw ?? '',
eval: x?.eval ?? undefined,
temp_eval: undefined
};
}
public getRowHeight(row: number) { public getRowHeight(row: number) {
return this.row_heights[row] ?? this.default_row_height; return this.row_heights[row] ?? this.default_row_height;
} }
@@ -105,6 +126,30 @@ class Grid {
return this.default_row_height; return this.default_row_height;
} }
public isActiveTop(pos: Position): boolean {
const tl = this.getActiveTopLeft();
if (!tl) return false;
return this.isActive(pos) && pos.row === tl.row;
}
public isActiveBottom(pos: Position): boolean {
const br = this.getActiveBottomRight();
if (!br) return false;
return this.isActive(pos) && pos.row === br.row;
}
public isActiveLeft(pos: Position): boolean {
const tl = this.getActiveTopLeft();
if (!tl) return false;
return this.isActive(pos) && pos.col === tl.col;
}
public isActiveRight(pos: Position): boolean {
const br = this.getActiveBottomRight();
if (!br) return false;
return this.isActive(pos) && pos.col === br.col;
}
public setRowHeight(row: number, height: string) { public setRowHeight(row: number, height: string) {
if (height === this.default_row_height) { if (height === this.default_row_height) {
delete this.row_heights[row]; delete this.row_heights[row];
@@ -121,8 +166,10 @@ class Grid {
} }
} }
public startEditing(pos: Position) { public startEditing(pos: Position | undefined) {
this.active_cell = pos; if (!pos) return;
this.setActive(pos, pos);
this.editing_cell = pos; this.editing_cell = pos;
let cell = this.getCell(pos); let cell = this.getCell(pos);
@@ -130,52 +177,139 @@ class Grid {
cell.temp_eval = undefined; cell.temp_eval = undefined;
} }
public stopEditing(pos: Position) { public stopEditing(pos: Position | null | undefined) {
if (!pos) return;
this.editing_cell = null;
// this.setCell(pos);
}
public stopAnyEditing() {
this.editing_cell = null; this.editing_cell = null;
this.setCell(pos);
} }
public stopEditingActive() { public stopEditingActive() {
if (this.active_cell == null) return; if (!this.anyIsActive() || !this.primary_active?.equals(this.secondary_active)) return;
this.stopEditing(this.active_cell); this.stopEditing(this.primary_active);
} }
public isEditing(pos: Position): boolean { public isEditing(pos: Position): boolean {
if (this.editing_cell == null) return false; if (this.editing_cell === null) return false;
return this.editing_cell.equals(pos); return this.editing_cell.equals(pos);
} }
public anyIsEditing(): boolean {
return this.editing_cell !== null;
}
public isExternalEditing(pos: Position): boolean { public isExternalEditing(pos: Position): boolean {
if (this.external_editing_cell == null) return false; if (this.external_editing_cell === null) return false;
return this.external_editing_cell.equals(pos); return this.external_editing_cell.equals(pos);
} }
public setActive(pos: Position | null) { public setActive(primary: Position | null, secondary: Position | null) {
this.active_cell = pos; this.primary_active = primary;
this.secondary_active = secondary;
} }
public setExternalEdit(pos: Position | null) { public setInactive() {
this.primary_active = null;
this.secondary_active = null;
}
public startExternalEdit(pos: Position | null) {
this.external_editing_cell = pos; this.external_editing_cell = pos;
} }
public stopExternalEdit(pos: Position | null) {
this.external_editing_cell = null;
}
public getActiveCell(): CellT | undefined { public getActiveCell(): CellT | undefined {
if (this.active_cell === null) if (this.primary_active === null || this.secondary_active === null) {
return { return {
raw: '', raw: '',
temp_raw: '', temp_raw: '',
pos: new Position(-1, -1),
eval: undefined eval: undefined
}; };
}
return this.getCell(this.active_cell); if (!this.primary_active.equals(this.secondary_active)) {
return {
raw: '',
temp_raw: '',
pos: new Position(-1, -1),
eval: undefined
};
}
return this.getCell(this.primary_active);
}
public getActiveRangeStr(): string {
const tl = this.getActiveTopLeft();
const br = this.getActiveBottomRight();
if (tl === null || br === null) return '';
// Single-cell selection
if (tl.equals(br)) return tl.str();
// Range selection
return `${tl.str()}:${br.str()}`;
} }
public getActivePos(): Position | null { public getActivePos(): Position | null {
return this.active_cell; if (
this.primary_active === null ||
this.secondary_active === null ||
!this.primary_active.equals(this.secondary_active)
) {
return null;
}
return this.primary_active;
} }
public isActive(pos: Position): boolean { public isActive(pos: Position): boolean {
if (this.active_cell == null) return false; if (this.primary_active === null || this.secondary_active === null) return false;
return this.active_cell.equals(pos);
return (
pos.row >= Math.min(this.primary_active.row, this.secondary_active.row) &&
pos.row <= Math.max(this.primary_active.row, this.secondary_active.row) &&
pos.col >= Math.min(this.primary_active.col, this.secondary_active.col) &&
pos.col <= Math.max(this.primary_active.col, this.secondary_active.col)
);
}
public getActiveTopLeft(): Position | null {
if (this.primary_active === null || this.secondary_active === null) return null;
return new Position(
Math.min(this.primary_active.row, this.secondary_active.row),
Math.min(this.primary_active.col, this.secondary_active.col)
);
}
public getActiveBottomRight(): Position | null {
if (this.primary_active === null || this.secondary_active === null) return null;
return new Position(
Math.max(this.primary_active.row, this.secondary_active.row),
Math.max(this.primary_active.col, this.secondary_active.col)
);
}
public isPrimaryActive(pos: Position): boolean {
if (this.primary_active === null) return false;
return this.primary_active.equals(pos);
}
public isSingleActive(): boolean {
return this.getActivePos() !== null;
}
public anyIsActive(): boolean {
return this.primary_active !== null && this.secondary_active !== null;
} }
public quickEval(pos: Position | null, raw: string) { public quickEval(pos: Position | null, raw: string) {
@@ -214,6 +348,7 @@ class Grid {
this.data[pos.key()] = { this.data[pos.key()] = {
raw: msg.raw ?? '', raw: msg.raw ?? '',
eval: msg.eval, eval: msg.eval,
pos: pos,
temp_raw: x?.temp_raw ?? '', temp_raw: x?.temp_raw ?? '',
temp_eval: x?.temp_eval ?? undefined temp_eval: x?.temp_eval ?? undefined
}; };

View File

@@ -1,3 +1,5 @@
import type { Position } from "./grid.svelte.ts";
interface LeadMsg { interface LeadMsg {
msg_type: 'set' | 'get' | 'error' | 'bulk' | 'eval'; msg_type: 'set' | 'get' | 'error' | 'bulk' | 'eval';
cell?: CellRef; cell?: CellRef;
@@ -48,6 +50,7 @@ type Eval =
interface CellT { interface CellT {
raw: string; raw: string;
temp_raw: string; temp_raw: string;
pos: Position;
temp_eval?: Eval; temp_eval?: Eval;
eval?: Eval; eval?: Eval;
} }

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};