This commit is contained in:
2025-09-11 02:51:47 +10:00
parent a2bd089730
commit 38bd4239fe
9 changed files with 223 additions and 72 deletions

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import clsx from 'clsx'; import clsx from 'clsx';
import { getErrDesc, getErrTitle, getEvalLiteral, isErr, type CellT } 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';
let { let {
cla = '', cla = '',
@@ -33,7 +34,7 @@
queueMicrotask(() => { queueMicrotask(() => {
const el = node.querySelector('input') as HTMLInputElement | null; const el = node.querySelector('input') as HTMLInputElement | null;
if (el !== null) { if (el !== null) {
el.value = cell?.raw_val ?? ''; el.value = cell?.temp_raw ?? '';
el.focus(); el.focus();
} }
}); });
@@ -49,44 +50,48 @@
stopediting(); stopediting();
} }
} }
function getPreview() {
return !isErr(cell?.temp_eval) ? getEvalLiteral(cell?.temp_eval) : '';
}
let showPreview = $derived(getPreview() !== '');
</script> </script>
{#if editing} {#if editing}
<div class="relative inline-block">
{#if showPreview}
<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"
role="tooltip"
>
{getPreview()}
</h3>
{/if}
<div use:autofocusWithin onkeydown={handleKeydown}> <div use:autofocusWithin onkeydown={handleKeydown}>
<Input <Input
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 ?? '',
return cell?.raw_val ?? ''; (v) => (cell = { eval: cell?.eval, raw: cell?.raw ?? '', temp_raw: v })
},
(v) => {
cell = {
val: cell?.val,
raw_val: v
};
} }
} onblur={stopediting}
onblur={(e) => {
// cell = {
// val: cell?.val,
// raw_val: (e.target as HTMLInputElement).value
// };
stopediting();
}}
/> />
</div> </div>
{:else if cell && isErr(cell.val)} </div>
{:else if cell && isErr(cell.eval)}
<HoverCard.Root openDelay={500} closeDelay={500}> <HoverCard.Root openDelay={500} closeDelay={500}>
<HoverCard.Trigger> <HoverCard.Trigger>
{@render InnerCell()} {@render InnerCell()}
</HoverCard.Trigger> </HoverCard.Trigger>
<HoverCard.Content side="right"> <HoverCard.Content side="right">
<h2 class="text-md font-semibold tracking-tight transition-colors"> <h2 class="text-md font-semibold tracking-tight transition-colors">
{getErrTitle(cell.val)} {getErrTitle(cell.eval)}
</h2> </h2>
{getErrDesc(cell.val)} {getErrDesc(cell.eval)}
</HoverCard.Content> </HoverCard.Content>
</HoverCard.Root> </HoverCard.Root>
{:else} {:else}
@@ -101,12 +106,12 @@
style:height style:height
class={clsx('placeholder bg-background p-1', { active }, cla)} class={clsx('placeholder bg-background p-1', { active }, cla)}
> >
{#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')} {#if cell && (cell.raw !== '' || getEvalLiteral(cell.eval) !== '')}
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}> <span class={clsx('pointer-events-none select-none', { err: isErr(cell.eval) })}>
{#if cell.val && !externalediting} {#if cell.eval && !externalediting}
{getEvalLiteral(cell.val)} {getEvalLiteral(cell.eval)}
{:else} {:else}
{cell.raw_val} {cell.raw}
{/if} {/if}
</span> </span>
{/if} {/if}
@@ -144,4 +149,37 @@
border-top: 12px solid red; /* size & color of the triangle */ border-top: 12px solid red; /* size & color of the triangle */
border-left: 12px solid transparent; border-left: 12px solid transparent;
} }
.bubble {
z-index: 500;
background: var(--color-popover);
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
border-radius: 10px;
color: var(--color-popover-foreground);
padding: 0.35rem 0.6rem;
box-shadow: 0 2px 18px rgba(0, 0, 0, 0.08);
max-width: min(15rem, 20vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
/* (optional) subtle appear animation */
@media (prefers-reduced-motion: no-preference) {
.bubble {
transform-origin: bottom left;
animation: bubble-in 120ms ease-out both;
}
@keyframes bubble-in {
from {
opacity: 0;
transform: translateY(2px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
}
</style> </style>

View File

@@ -32,8 +32,8 @@
}; };
const grid = new Grid(socket); const grid = new Grid(socket);
let rows = 100; let rows = 10;
let cols = 40; let cols = 10;
function handleCellInteraction(i: number, j: number, e: MouseEvent) { function handleCellInteraction(i: number, j: number, e: MouseEvent) {
let pos = new Position(i, j); let pos = new Position(i, j);
@@ -104,9 +104,12 @@
/> />
<Input <Input
onmousedown={() => grid.setExternalEdit(grid.getActivePos())} onmousedown={() => grid.setExternalEdit(grid.getActivePos())}
onblur={() => grid.setExternalEdit(null)} onblur={() => grid.setCell(grid.getActivePos())}
bind:value={ bind:value={
() => grid.getActiveCell()?.raw_val ?? '', (raw) => grid.quickEval(grid.getActivePos(), raw) () => grid.getActiveCell()?.temp_raw ?? '',
(v) => {
grid.setCellTemp(grid.getActivePos(), v);
}
} }
class="relative w-[200px] pl-9" class="relative w-[200px] pl-9"
></Input> ></Input>
@@ -114,7 +117,10 @@
</div> </div>
<div <div
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)} class={clsx(
' 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}">
@@ -163,7 +169,8 @@
handleCellInteraction(i, j, e); handleCellInteraction(i, j, e);
}} }}
bind:cell={ bind:cell={
() => grid.getCell(new Position(i, j)), (v) => grid.setCell(new Position(i, j), v) () => grid.getCell(new Position(i, j)),
(v) => grid.setCellTemp(new Position(i, j), v?.temp_raw)
} }
active={grid.isActive(new Position(i, j))} active={grid.isActive(new Position(i, j))}
/> />

View File

@@ -1,5 +1,5 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import type { CellRef, CellT, LeadMsg } from './messages'; import type { CellRef, CellT, Eval, LeadMsg } from './messages';
class Position { class Position {
public row: number; public row: number;
@@ -41,7 +41,7 @@ class Grid {
active_cell: Position | null = $state(null); active_cell: 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 = $state(null); editing_preview: [Eval, boolean] | null = $state(null); // [Eval, dirty]
constructor(socket: WebSocket, default_col_width = '80px', default_row_height = '30px') { constructor(socket: WebSocket, default_col_width = '80px', default_row_height = '30px') {
this.socket = socket; this.socket = socket;
@@ -49,30 +49,47 @@ class Grid {
this.default_row_height = default_row_height; this.default_row_height = default_row_height;
} }
public getCell(pos: Position): CellT { public getCell(pos: Position): CellT | undefined {
return this.data[pos.key()]; return this.data[pos.key()];
} }
public setCell(pos: Position, v: CellT) { public setCell(pos: Position | null) {
if (v?.raw_val == null || v.raw_val === '') { if (pos === null) return;
let data = this.data[pos.key()];
if (data === undefined) return;
if (data.temp_raw === '') {
delete this.data[pos.key()]; delete this.data[pos.key()];
return; return;
} }
this.data[pos.key()] = { data.raw = data.temp_raw;
raw_val: v?.raw_val, data.eval = data.temp_eval;
val: v.val
};
let msg: LeadMsg = { let msg: LeadMsg = {
msg_type: 'set', msg_type: 'set',
cell: pos.ref(), cell: pos.ref(),
raw: v.raw_val raw: data.temp_raw
}; };
this.socket.send(JSON.stringify(msg)); this.socket.send(JSON.stringify(msg));
} }
public setCellTemp(pos: Position | null, raw: string | undefined) {
if (pos === null || raw === undefined) return;
let x = this.data[pos.key()];
this.data[pos.key()] = {
raw: x?.raw ?? '',
temp_raw: raw,
eval: x?.eval ?? undefined,
temp_eval: x?.temp_eval ?? undefined
};
this.quickEval(pos, raw);
}
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;
} }
@@ -107,16 +124,19 @@ class Grid {
public startEditing(pos: Position) { public startEditing(pos: Position) {
this.active_cell = pos; this.active_cell = pos;
this.editing_cell = pos; this.editing_cell = pos;
let cell = this.getCell(pos);
if (!cell) return;
cell.temp_eval = undefined;
} }
public stopEditing(pos: Position) { public stopEditing(pos: Position) {
this.editing_cell = null; this.editing_cell = null;
this.setCell(pos, this.getCell(pos)); this.setCell(pos);
} }
public stopEditingActive() { public stopEditingActive() {
if (this.active_cell == null) return; if (this.active_cell == null) return;
this.stopEditing(this.active_cell); this.stopEditing(this.active_cell);
} }
@@ -138,11 +158,12 @@ class Grid {
this.external_editing_cell = pos; this.external_editing_cell = pos;
} }
public getActiveCell(): CellT { public getActiveCell(): CellT | undefined {
if (this.active_cell === null) if (this.active_cell === null)
return { return {
raw_val: '', raw: '',
val: undefined temp_raw: '',
eval: undefined
}; };
return this.getCell(this.active_cell); return this.getCell(this.active_cell);
@@ -179,16 +200,22 @@ class Grid {
} }
case 'set': { case 'set': {
if (msg.cell === undefined) { if (msg.cell === undefined) {
console.error('Expected cell ref for SET msgponse from server.'); console.error('Expected cell ref for SET msg from server.');
return; return;
} else if (msg.eval === undefined) { } else if (msg.eval === undefined) {
console.error('Expected cell value for SET msgponse from server.'); console.error('Expected cell value for SET msg from server.');
return; return;
} }
this.data[Position.key(msg.cell.row, msg.cell.col)] = { let pos = new Position(msg.cell.row, msg.cell.col);
raw_val: msg.raw ?? '',
val: msg.eval let x = this.data[pos.key()];
this.data[pos.key()] = {
raw: msg.raw ?? '',
eval: msg.eval,
temp_raw: x?.temp_raw ?? '',
temp_eval: x?.temp_eval ?? undefined
}; };
break; break;
@@ -200,9 +227,23 @@ class Grid {
} }
for (const m of msg.bulk_msgs) this.handle_msg(m); for (const m of msg.bulk_msgs) this.handle_msg(m);
break;
} }
case 'eval': { case 'eval': {
// TODO if (msg.cell === undefined) {
console.error('Expected cell ref for EVAL msg from server.');
return;
} else if (msg.eval === undefined) {
console.error('Expected cell value for EVAL msg from server.');
return;
}
let pos = new Position(msg.cell.row, msg.cell.col);
if (this.data[pos.key()] === undefined) return;
this.data[pos.key()].temp_eval = msg.eval;
break;
} }
} }
} }

View File

@@ -45,9 +45,11 @@ type Eval =
| { err: LeadErr } | { err: LeadErr }
| 'unset'; | 'unset';
interface CellT { interface CellT {
raw_val: string; raw: string;
val?: Eval; temp_raw: string;
temp_eval?: Eval;
eval?: Eval;
} }
export type { Eval, LeadMsg, LeadErr, Literal, CellRef, LiteralValue, CellT }; export type { Eval, LeadMsg, LeadErr, Literal, CellRef, LiteralValue, CellT };

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui"; import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
sideOffset = 0, sideOffset = 0,
side = "top", side = 'top',
children, children,
arrowClasses, arrowClasses,
...restProps ...restProps
@@ -22,7 +22,7 @@
{sideOffset} {sideOffset}
{side} {side}
class={cn( class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs", 'z-[400] w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className className
)} )}
{...restProps} {...restProps}
@@ -32,11 +32,11 @@
{#snippet child({ props })} {#snippet child({ props })}
<div <div
class={cn( class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]", 'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]", 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]", 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2", 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
"data-[side=left]:-translate-y-[calc(50%_-_3px)]", 'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses arrowClasses
)} )}
{...props} {...props}