- moved grid rendering to feature

- re implemented file loading for order preservation
- added styling for grid
- implemented row selection POC
This commit is contained in:
Chris Kruining 2024-10-07 16:54:45 +02:00
parent 70c15c4094
commit ebd8ff8c1d
10 changed files with 897 additions and 143 deletions

106
src/features/file/grid.css Normal file
View file

@ -0,0 +1,106 @@
.table {
position: relative;
display: grid;
grid-template-columns: 2em minmax(10em, max-content) repeat(var(--columns), auto);
align-content: start;
padding-inline: 1px;
margin-inline: -1px;
block-size: 100%;
overflow: clip auto;
background-color: var(--surface-1);
& input[type="checkbox"] {
margin: .1em;
}
& textarea {
resize: vertical;
min-block-size: 2em;
max-block-size: 50em;
}
& .cell {
display: grid;
place-content: center stretch;
padding: .5em;
}
& > :is(header, main, footer) {
grid-column: span calc(2 + var(--columns));
display: grid;
grid-template-columns: subgrid;
}
& > header {
position: sticky;
inset-block-start: 0;
background-color: inherit;
}
& label {
--bg: var(--text);
--alpha: 0;
grid-column: span calc(2 + var(--columns));
display: grid;
grid-template-columns: subgrid;
border: 1px solid transparent;
background-color: color(from var(--bg) srgb r g b / var(--alpha));
&:has(> .cell > :checked) {
--bg: var(--info);
--alpha: .1;
border-color: var(--bg);
& span {
font-variation-settings: 'GRAD' 1000;
}
& + :has(> .cell> :checked) {
border-block-start-color: transparent;
}
&:has(+ label > .cell > :checked) {
border-block-end-color: transparent;
}
}
&:hover {
--alpha: .2 !important;
}
}
& details {
display: contents;
&::details-content {
grid-column: span calc(2 + var(--columns));
display: grid;
grid-template-columns: subgrid;
}
&:not([open])::details-content {
display: none;
}
& > summary {
grid-column: 2 / span calc(1 + var(--columns));
&.cell {
padding-inline-start: calc((var(--depth) + 1) * 1em);
}
}
& > label > .cell > span {
padding-inline-start: calc(var(--depth) * 1.25em);
}
}
}
@property --depth {
syntax: "<number>";
inherits: false;
initial-value: 0;
}

134
src/features/file/grid.tsx Normal file
View file

@ -0,0 +1,134 @@
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js";
import './grid.css';
import { createStore } from "solid-js/store";
interface Leaf extends Record<string, string> { }
export interface Entry extends Record<string, Entry | Leaf> { }
interface SelectionContextType {
rowCount(): number;
selection(): string[];
isSelected(key: string): boolean,
selectAll(select: boolean): void;
select(key: string, select: true): void;
}
const SelectionContext = createContext<SelectionContextType>();
const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string');
const useSelection = () => useContext(SelectionContext)!;
const SelectionProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>, context?: (ctx: SelectionContextType) => any }> = (props) => {
const [state, setState] = createStore<{ selection: string[] }>({ selection: [] });
const rowKeys = createMemo(() => {
return Array.from(props.rows?.keys());
});
const context = {
rowCount() {
return rowKeys().length;
},
selection() {
return state.selection;
},
isSelected(key: string) {
return state.selection.includes(key);
},
selectAll(selected: boolean) {
setState('selection', selected ? rowKeys() : []);
},
select(key: string, select: true) {
setState('selection', selection => {
if (select) {
return [...selection, key];
}
return selection.toSpliced(selection.indexOf(key), 1);
});
},
};
createEffect(() => {
props.context?.(context)
});
return <SelectionContext.Provider value={context}>
{props.children}
</SelectionContext.Provider>;
};
export const Grid: Component<{ columns: string[], rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>, context?: (ctx: SelectionContextType) => any }> = (props) => {
const columnCount = createMemo(() => props.columns.length - 1);
const root = createMemo<Entry>(() => {
return Object.fromEntries(props.rows.entries().map(([key, value]) => [key, Object.fromEntries(Object.entries(value).map(([lang, { value }]) => [lang, value]))]));
});
return <section class="table" style={{ '--columns': columnCount() }}>
<SelectionProvider rows={props.rows} context={props.context}>
<Head headers={props.columns} />
<main>
<Row entry={root()} />
</main>
</SelectionProvider>
</section>
};
const Head: Component<{ headers: string[] }> = (props) => {
const context = useSelection();
return <header>
<div class="cell">
<input
type="checkbox"
checked={context.selection().length > 0 && context.selection().length === context.rowCount()}
indeterminate={context.selection().length !== 0 && context.selection().length !== context.rowCount()}
on:input={(e: InputEvent) => context.selectAll(e.target.checked)}
/>
</div>
<For each={props.headers}>{
header => <span class="cell">{header}</span>
}</For>
</header>;
};
const Row: Component<{ entry: Entry, path?: string[] }> = (props) => {
return <For each={Object.entries(props.entry)}>{
([key, value]) => {
const values = Object.values(value);
const path = [...(props.path ?? []), key];
const k = path.join('.');
const context = useSelection();
return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}>
<label for={k}>
<div class="cell">
<input type="checkbox" id={k} checked={context.isSelected(k)} on:input={(e: InputEvent) => context.select(k, e.target.checked)} />
</div>
<div class="cell">
<span style={{ '--depth': path.length - 1 }}>{key}</span>
</div>
<For each={values}>{
value =>
<div class="cell"><textarea value={value} on:keyup={(e) => {
e.target.style.blockSize = `1px`;
e.target.style.blockSize = `${11 + e.target.scrollHeight}px`;
}} /></div>
}</For>
</label>
</Show>;
}
}</For>
};
const Group: Component<{ key: string, entry: Entry, path: string[] }> = (props) => {
return <details open>
<summary class="cell" style={{ '--depth': props.path.length - 1 }}>{props.key}</summary>
<Row entry={props.entry} path={props.path} />
</details>;
};

View file

@ -1,21 +1,22 @@
import Dexie, { EntityTable } from "dexie";
import { Component, createContext, useContext } from "solid-js";
import { createContext, useContext } from "solid-js";
import { isServer } from "solid-js/web";
import * as json from './parser/json';
type Handle = FileSystemFileHandle|FileSystemDirectoryHandle;
type Handle = FileSystemFileHandle | FileSystemDirectoryHandle;
interface File {
interface FileEntity {
name: string;
handle: Handle;
}
type Store = Dexie & {
files: EntityTable<File, 'name'>;
files: EntityTable<FileEntity, 'name'>;
};
interface FilesContextType {
set(name: string, handle: Handle): Promise<void>;
get(name: string): Promise<Handle|undefined>;
get(name: string): Promise<Handle | undefined>;
list(): Promise<Handle[]>;
}
@ -37,20 +38,20 @@ const clientContext = (): FilesContextType => {
},
async list() {
const files = await db.files.toArray();
return files.map(f => f.handle)
},
}
};
const serverContext = (): FilesContextType => ({
set(){
set() {
return Promise.resolve();
},
get(name: string){
get(name: string) {
return Promise.resolve(undefined);
},
list(){
list() {
return Promise.resolve<Handle[]>([]);
},
});
@ -63,6 +64,13 @@ export const FilesProvider = (props) => {
export const useFiles = () => useContext(FilesContext)!;
export const open = () => {
export const load = (file: File): Promise<Map<string, string> | undefined> => {
switch (file.type) {
case 'application/json': return json.load(file.stream())
};
default: return Promise.resolve(undefined);
}
};
export { Grid } from './grid';
export type { Entry } from './grid';

View file

@ -0,0 +1,199 @@
export async function load(stream: ReadableStream<Uint8Array>): Promise<Map<string, string>> {
return new Map(await Array.fromAsync(parse(stream), ({ key, value }) => [key, value]));
}
interface Entry {
key: string;
value: string;
}
interface State {
(token: Token): State;
entry?: Entry
}
const states = {
none(): State {
return (token: Token) => {
if (token.kind === 'braceOpen') {
return states.object();
}
return states.none;
};
},
object({ path = [], expect = 'key' }: Partial<{ path: string[], expect: 'key' | 'colon' | 'value' }> = {}): State {
return (token: Token) => {
switch (expect) {
case 'key': {
if (token.kind === 'braceClose') {
return states.object({
path: path.slice(0, -1),
expect: 'key',
});
}
else if (token.kind === 'string') {
return states.object({
path: [...path, token.value],
expect: 'colon'
});
}
return states.error(`Expected a key, got ${token.kind} instead`);
}
case 'colon': {
if (token.kind !== 'colon') {
return states.error(`Expected a ':', got ${token.kind} instead`);
}
return states.object({
path,
expect: 'value'
});
}
case 'value': {
if (token.kind === 'braceOpen') {
return states.object({
path,
expect: 'key',
});
}
else if (token.kind === 'string') {
const next = states.object({
path: path.slice(0, -1),
expect: 'key',
});
next.entry = { key: path.join('.'), value: token.value };
return next
}
return states.error(`Invalid value type found '${token.kind}'`);
}
}
return states.none();
}
},
error(message: string): State {
throw new Error(message);
return states.none();
},
} as const;
async function* parse(stream: ReadableStream<Uint8Array>): AsyncGenerator<any, void, unknown> {
let state = states.none();
for await (const token of take(tokenize(read(toGenerator(stream))), 100)) {
try {
state = state(token);
}
catch (e) {
console.error(e);
break;
}
if (state.entry) {
yield state.entry;
}
}
}
async function* take<T>(iterable: AsyncIterable<T>, numberToTake: number): AsyncGenerator<T, void, unknown> {
let i = 0;
for await (const entry of iterable) {
yield entry;
i++;
if (i === numberToTake) {
break;
}
}
}
type Token = { start: number, length: number } & (
| { kind: 'braceOpen' }
| { kind: 'braceClose' }
| { kind: 'colon' }
| { kind: 'string', value: string }
);
async function* tokenize(characters: AsyncIterable<number>): AsyncGenerator<Token, void, unknown> {
let buffer: string = '';
let clearBuffer = false;
let start = 0;
let i = 0;
for await (const character of characters) {
if (buffer.length === 0) {
start = i;
}
buffer += String.fromCharCode(character);
const length = buffer.length;
if (buffer === '{') {
yield { kind: 'braceOpen', start, length };
clearBuffer = true;
}
else if (buffer === '}') {
yield { kind: 'braceClose', start, length };
clearBuffer = true;
}
else if (buffer === ':') {
yield { kind: 'colon', start, length };
clearBuffer = true;
}
else if (buffer.length > 1 && buffer.startsWith('"') && buffer.endsWith('"') && buffer.at(-2) !== '\\') {
yield { kind: 'string', start, length, value: buffer.slice(1, buffer.length - 1) };
clearBuffer = true;
}
else if (buffer === ',') {
clearBuffer = true;
}
else if (buffer.trim() === '') {
clearBuffer = true;
}
if (clearBuffer) {
buffer = '';
clearBuffer = false;
}
i++;
}
}
async function* read(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<number, void, unknown> {
for await (const chunk of chunks) {
for (const character of chunk) {
yield character;
}
}
}
async function* toGenerator<T>(stream: ReadableStream<T>): AsyncGenerator<T, void, unknown> {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
yield value;
}
}
finally {
reader.releaseLock();
}
}

View file

@ -91,7 +91,7 @@ const Item: Component<ItemProps> = (props) => {
return mergeProps(props, {
id,
get children() {
return childItems();
return childItems.toArray();
}
}) as unknown as JSX.Element;
}