quick snapshot
This commit is contained in:
parent
b03f80f34f
commit
5367ed2f44
10 changed files with 309 additions and 191 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { Accessor, children, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, For, JSX, onMount, ParentComponent, Show, useContext } from "solid-js";
|
import { Accessor, children, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, For, JSX, onMount, ParentComponent, Setter, Show, useContext } from "solid-js";
|
||||||
import css from "./tabs.module.css";
|
import css from "./tabs.module.css";
|
||||||
|
|
||||||
interface TabsContextType {
|
interface TabsContextType {
|
||||||
|
@ -17,37 +17,18 @@ const useTabs = () => {
|
||||||
return context!;
|
return context!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tabs: ParentComponent = (props) => {
|
export const Tabs: ParentComponent<{ active?: Setter<string | undefined> }> = (props) => {
|
||||||
const [active, setActive] = createSignal<string | undefined>(undefined);
|
const [active, setActive] = createSignal<string | undefined>(undefined);
|
||||||
const [tabs, setTabs] = createSignal<{ id: string, label: string }[]>([]);
|
const [tabs, setTabs] = createSignal<{ id: string, label: string }[]>([]);
|
||||||
// const resolved = children(() => props.children);
|
|
||||||
// const resolvedArray = createMemo(() => resolved.toArray());
|
|
||||||
// const tabs = createMemo(() => resolvedArray().map(t => ({ id: t.id, label: t.dataset?.label ?? '' })));
|
|
||||||
|
|
||||||
// createEffect(() => {
|
createEffect(() => {
|
||||||
// for (const t of resolvedArray()) {
|
props.active?.(active());
|
||||||
// console.log(t);
|
});
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setActive(tabs().at(-1)?.id);
|
setActive(tabs().at(-1)?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// createRenderEffect(() => {
|
|
||||||
// if (isServer) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for (const t of resolvedArray().filter(t => t instanceof HTMLElement)) {
|
|
||||||
// if (active() === t.id) {
|
|
||||||
// t.classList.add(css.active);
|
|
||||||
// } else {
|
|
||||||
// t.classList.remove(css.active);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
register(id: string, label: string) {
|
register(id: string, label: string) {
|
||||||
setTabs(tabs => [...tabs, { id, label }]);
|
setTabs(tabs => [...tabs, { id, label }]);
|
||||||
|
@ -56,10 +37,6 @@ export const Tabs: ParentComponent = (props) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(tabs());
|
|
||||||
});
|
|
||||||
|
|
||||||
return <TabsContext.Provider value={ctx}>
|
return <TabsContext.Provider value={ctx}>
|
||||||
<div class={css.tabs}>
|
<div class={css.tabs}>
|
||||||
<header>
|
<header>
|
||||||
|
|
|
@ -9,12 +9,12 @@
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
gap: var(--padding-m);
|
gap: var(--padding-m);
|
||||||
padding: var(--padding-m);
|
padding: var(--padding-m) 0;
|
||||||
font-size: var(--text-s);
|
font-size: var(--text-s);
|
||||||
|
|
||||||
background-color: var(--surface-1);
|
background-color: var(--surface-1);
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
border: 1px solid var(--text-1);
|
border: 1px solid var(--surface-5);
|
||||||
border-radius: var(--radii-m);
|
border-radius: var(--radii-m);
|
||||||
|
|
||||||
& > li {
|
& > li {
|
||||||
|
@ -23,10 +23,16 @@
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
padding: var(--padding-s) var(--padding-m);
|
||||||
|
|
||||||
& > sub {
|
& > sub {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-align: end;
|
text-align: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:popover-open {
|
&:popover-open {
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
& textarea {
|
& textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-block-size: 2em;
|
min-block-size: max(2em, 100%);
|
||||||
max-block-size: 50em;
|
max-block-size: 50em;
|
||||||
|
|
||||||
background-color: var(--surface-1);
|
background-color: var(--surface-1);
|
||||||
|
@ -39,10 +39,6 @@
|
||||||
& > span {
|
& > span {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > textarea {
|
|
||||||
min-block-size: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& :is(.header, .main, .footer) {
|
& :is(.header, .main, .footer) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Accessor, Component, createContext, createEffect, createMemo, createRenderEffect, createSignal, For, onMount, ParentComponent, Show, useContext } from "solid-js";
|
import { Accessor, Component, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, For, onMount, ParentComponent, Show, useContext } from "solid-js";
|
||||||
import { createStore, unwrap } from "solid-js/store";
|
import { createStore, unwrap } from "solid-js/store";
|
||||||
import { SelectionProvider, useSelection, selectable } from "../selectable";
|
import { SelectionProvider, useSelection, selectable } from "../selectable";
|
||||||
import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities";
|
import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities";
|
||||||
|
@ -10,15 +10,17 @@ interface Leaf extends Record<string, string> { }
|
||||||
export interface Entry extends Record<string, Entry | Leaf> { }
|
export interface Entry extends Record<string, Entry | Leaf> { }
|
||||||
|
|
||||||
type Rows = Map<string, Record<string, string>>;
|
type Rows = Map<string, Record<string, string>>;
|
||||||
|
type SelectionItem = { key: string, value: Accessor<Record<string, string>>, element: WeakRef<HTMLElement> };
|
||||||
|
|
||||||
export interface GridContextType {
|
export interface GridContextType {
|
||||||
readonly rows: Accessor<Record<string, Record<string, string>>>;
|
readonly rows: Accessor<Record<string, Record<string, string>>>;
|
||||||
readonly mutations: Accessor<Mutation[]>;
|
readonly mutations: Accessor<Mutation[]>;
|
||||||
readonly selection: Accessor<object[]>;
|
readonly selection: Accessor<SelectionItem[]>;
|
||||||
mutate(prop: string, lang: string, value: string): void;
|
mutate(prop: string, lang: string, value: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridApi {
|
export interface GridApi {
|
||||||
|
readonly selection: Accessor<Record<string, Record<string, string>>>;
|
||||||
readonly rows: Accessor<Record<string, Record<string, string>>>;
|
readonly rows: Accessor<Record<string, Record<string, string>>>;
|
||||||
readonly mutations: Accessor<Mutation[]>;
|
readonly mutations: Accessor<Mutation[]>;
|
||||||
selectAll(): void;
|
selectAll(): void;
|
||||||
|
@ -31,7 +33,7 @@ const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some
|
||||||
const useGrid = () => useContext(GridContext)!;
|
const useGrid = () => useContext(GridContext)!;
|
||||||
|
|
||||||
const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
|
const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
|
||||||
const [selection, setSelection] = createSignal<object[]>([]);
|
const [selection, setSelection] = createSignal<SelectionItem[]>([]);
|
||||||
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
|
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
|
||||||
rows: {},
|
rows: {},
|
||||||
snapshot: new Map,
|
snapshot: new Map,
|
||||||
|
@ -106,9 +108,14 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap
|
||||||
|
|
||||||
const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => {
|
const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => {
|
||||||
const gridContext = useGrid();
|
const gridContext = useGrid();
|
||||||
const selectionContext = useSelection();
|
const selectionContext = useSelection<{ key: string, value: Accessor<Record<string, string>>, element: WeakRef<HTMLElement> }>();
|
||||||
|
|
||||||
const api: GridApi = {
|
const api: GridApi = {
|
||||||
|
selection: createMemo(() => {
|
||||||
|
const selection = selectionContext.selection();
|
||||||
|
|
||||||
|
return Object.fromEntries(selection.map(({ key, value }) => [key, value()] as const));
|
||||||
|
}),
|
||||||
rows: gridContext.rows,
|
rows: gridContext.rows,
|
||||||
mutations: gridContext.mutations,
|
mutations: gridContext.mutations,
|
||||||
selectAll() {
|
selectAll() {
|
||||||
|
@ -160,7 +167,7 @@ const Row: Component<{ entry: Entry, path?: string[] }> = (props) => {
|
||||||
return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}>
|
return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}>
|
||||||
<div class={css.row} use:selectable={{ value, key: k }}>
|
<div class={css.row} use:selectable={{ value, key: k }}>
|
||||||
<div class={css.cell}>
|
<div class={css.cell}>
|
||||||
<input type="checkbox" checked={isSelected()} on:input={() => context.select([k])} />
|
<input type="checkbox" checked={isSelected()} on:input={() => context.select([k])} on:pointerdown={e => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={css.cell}>
|
<div class={css.cell}>
|
||||||
|
@ -217,6 +224,13 @@ const TextArea: Component<{ key: string, value: string, lang: string, oninput?:
|
||||||
resize();
|
resize();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const observer = new MutationObserver((e) => {
|
||||||
|
if (element()?.isConnected) {
|
||||||
|
resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
return <textarea
|
return <textarea
|
||||||
ref={setElement}
|
ref={setElement}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
|
@ -226,5 +240,7 @@ const TextArea: Component<{ key: string, value: string, lang: string, oninput?:
|
||||||
spellcheck
|
spellcheck
|
||||||
wrap="soft"
|
wrap="soft"
|
||||||
onkeyup={onKeyUp}
|
onkeyup={onKeyUp}
|
||||||
|
on:keydown={e => e.stopPropagation()}
|
||||||
|
on:pointerdown={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
};
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
.item {
|
.item {
|
||||||
padding: .5em 1em;
|
padding: var(--padding-m) var(--padding-l);
|
||||||
|
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
|
@ -34,6 +34,13 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .separator {
|
||||||
|
grid-column: span 2;
|
||||||
|
inline-size: calc(100% - (2 * var(--padding-m)));
|
||||||
|
margin-block: 0;
|
||||||
|
border: 1px solid var(--surface-5);
|
||||||
|
}
|
||||||
|
|
||||||
& > .item {
|
& > .item {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Accessor, Component, For, JSX, ParentComponent, Setter, Show, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, useContext } from "solid-js";
|
import { Accessor, Component, For, JSX, Match, ParentComponent, Setter, Show, Switch, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, useContext } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { CommandType, Command } from "../command";
|
import { CommandType, Command } from "../command";
|
||||||
|
@ -9,8 +9,8 @@ export interface MenuContextType {
|
||||||
ref: Accessor<Node | undefined>;
|
ref: Accessor<Node | undefined>;
|
||||||
setRef: Setter<Node | undefined>;
|
setRef: Setter<Node | undefined>;
|
||||||
|
|
||||||
addItems(items: (Item | ItemWithChildren)[]): void;
|
addItems(items: (Item | Separator | ItemWithChildren)[]): void;
|
||||||
items: Accessor<(Item | ItemWithChildren)[]>;
|
items: Accessor<(Item | Separator | ItemWithChildren)[]>;
|
||||||
commands(): CommandType[];
|
commands(): CommandType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -21,11 +21,15 @@ export interface Item {
|
||||||
command: CommandType;
|
command: CommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Separator {
|
||||||
|
kind: 'separator';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemWithChildren {
|
export interface ItemWithChildren {
|
||||||
kind: 'node';
|
kind: 'node';
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
children: Item[];
|
children: (Item | Separator)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuContext = createContext<MenuContextType>();
|
const MenuContext = createContext<MenuContextType>();
|
||||||
|
@ -51,7 +55,7 @@ export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (prop
|
||||||
},
|
},
|
||||||
commands() {
|
commands() {
|
||||||
return Object.values(store.items)
|
return Object.values(store.items)
|
||||||
.map(item => item.kind === 'node' ? item.children.map(c => c.command) : item.command)
|
.map(item => item.kind === 'node' ? item.children.filter(c => c.kind === 'leaf').map(c => c.command) : item.command)
|
||||||
.flat()
|
.flat()
|
||||||
.concat(props.commands ?? []);
|
.concat(props.commands ?? []);
|
||||||
},
|
},
|
||||||
|
@ -90,6 +94,10 @@ const Item: Component<ItemProps> = (props) => {
|
||||||
}) as unknown as JSX.Element;
|
}) as unknown as JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Separator: Component = (props) => {
|
||||||
|
return mergeProps(props, { kind: 'separator' }) as unknown as JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
const Root: ParentComponent<{}> = (props) => {
|
const Root: ParentComponent<{}> = (props) => {
|
||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
const [current, setCurrent] = createSignal<HTMLElement>();
|
const [current, setCurrent] = createSignal<HTMLElement>();
|
||||||
|
@ -125,7 +133,8 @@ const Root: ParentComponent<{}> = (props) => {
|
||||||
|
|
||||||
return <Portal mount={menu.ref()}>
|
return <Portal mount={menu.ref()}>
|
||||||
<For each={items}>{
|
<For each={items}>{
|
||||||
item => <Show when={Object.hasOwn(item, 'children') ? item as ItemWithChildren : undefined} fallback={<Child command={item.command} />}>{
|
item => <Switch>
|
||||||
|
<Match when={item.kind === 'node' ? item as ItemWithChildren : undefined}>{
|
||||||
item => <>
|
item => <>
|
||||||
<div
|
<div
|
||||||
class={css.child}
|
class={css.child}
|
||||||
|
@ -138,9 +147,15 @@ const Root: ParentComponent<{}> = (props) => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={item().children}>
|
<For each={item().children}>{
|
||||||
{(child) => <Child command={child.command} />}
|
child => <Switch>
|
||||||
</For>
|
<Match when={child.kind === 'leaf' ? child as Item : undefined}>{
|
||||||
|
item => <Child command={item().command} />
|
||||||
|
}</Match>
|
||||||
|
|
||||||
|
<Match when={child.kind === 'separator'}><hr class={css.separator} /></Match>
|
||||||
|
</Switch>
|
||||||
|
}</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@ -152,7 +167,12 @@ const Root: ParentComponent<{}> = (props) => {
|
||||||
{item().label}
|
{item().label}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}</Show>
|
}</Match>
|
||||||
|
|
||||||
|
<Match when={item.kind === 'leaf' ? item as Item : undefined}>{
|
||||||
|
item => <Child command={item().command} />
|
||||||
|
}</Match>
|
||||||
|
</Switch>
|
||||||
}</For>
|
}</For>
|
||||||
</Portal>
|
</Portal>
|
||||||
};
|
};
|
||||||
|
@ -207,7 +227,7 @@ export const asMenuRoot = (element: Element) => {
|
||||||
menu.setRef(element);
|
menu.setRef(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Menu = { Root, Item } as const;
|
export const Menu = { Root, Item, Separator } as const;
|
||||||
|
|
||||||
export interface CommandPaletteApi {
|
export interface CommandPaletteApi {
|
||||||
readonly open: Accessor<boolean>;
|
readonly open: Accessor<boolean>;
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { Accessor, children, createContext, createEffect, createMemo, createRend
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { isServer } from "solid-js/web";
|
import { isServer } from "solid-js/web";
|
||||||
import css from "./index.module.css";
|
import css from "./index.module.css";
|
||||||
import { isFocusable } from "~/utilities";
|
|
||||||
|
|
||||||
enum Modifier {
|
enum Modifier {
|
||||||
None = 0,
|
None = 0,
|
||||||
|
@ -17,8 +16,8 @@ enum SelectionMode {
|
||||||
Toggle,
|
Toggle,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectionContextType {
|
export interface SelectionContextType<T extends object = object> {
|
||||||
readonly selection: Accessor<object[]>;
|
readonly selection: Accessor<T[]>;
|
||||||
readonly length: Accessor<number>;
|
readonly length: Accessor<number>;
|
||||||
select(selection: string[], options?: Partial<{ mode: SelectionMode }>): void;
|
select(selection: string[], options?: Partial<{ mode: SelectionMode }>): void;
|
||||||
selectAll(): void;
|
selectAll(): void;
|
||||||
|
@ -31,19 +30,21 @@ interface InternalSelectionContextType {
|
||||||
readonly selectables: Signal<HTMLElement[]>,
|
readonly selectables: Signal<HTMLElement[]>,
|
||||||
add(key: string, value: object, element: HTMLElement): void;
|
add(key: string, value: object, element: HTMLElement): void;
|
||||||
}
|
}
|
||||||
export type SelectionHandler = (selection: object[]) => any;
|
export interface SelectionHandler<T extends object = object> {
|
||||||
|
(selection: T[]): any;
|
||||||
|
}
|
||||||
|
|
||||||
const SelectionContext = createContext<SelectionContextType>();
|
const SelectionContext = createContext<SelectionContextType>();
|
||||||
const InternalSelectionContext = createContext<InternalSelectionContextType>();
|
const InternalSelectionContext = createContext<InternalSelectionContextType>();
|
||||||
|
|
||||||
export const useSelection = () => {
|
export function useSelection<T extends object = object>(): SelectionContextType<T> {
|
||||||
const context = useContext(SelectionContext);
|
const context = useContext(SelectionContext);
|
||||||
|
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('selection context is used outside of a provider');
|
throw new Error('selection context is used outside of a provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context as SelectionContextType<T>;
|
||||||
};
|
};
|
||||||
const useInternalSelection = () => useContext(InternalSelectionContext)!;
|
const useInternalSelection = () => useContext(InternalSelectionContext)!;
|
||||||
|
|
||||||
|
@ -69,8 +70,6 @@ export const SelectionProvider: ParentComponent<{ selection?: SelectionHandler,
|
||||||
mode = SelectionMode.Toggle;
|
mode = SelectionMode.Toggle;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(selection, mode);
|
|
||||||
|
|
||||||
setState('selection', existing => {
|
setState('selection', existing => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case SelectionMode.Toggle: {
|
case SelectionMode.Toggle: {
|
||||||
|
@ -127,6 +126,7 @@ const Root: ParentComponent = (props) => {
|
||||||
|
|
||||||
if (!isServer && r) {
|
if (!isServer && r) {
|
||||||
const findSelectables = () => {
|
const findSelectables = () => {
|
||||||
|
setTimeout(() => {
|
||||||
setSelectables(Array.from((function* () {
|
setSelectables(Array.from((function* () {
|
||||||
const iterator = document.createTreeWalker(r, NodeFilter.SHOW_ELEMENT, {
|
const iterator = document.createTreeWalker(r, NodeFilter.SHOW_ELEMENT, {
|
||||||
acceptNode: (node: HTMLElement) => node.dataset.selectionKey ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
|
acceptNode: (node: HTMLElement) => node.dataset.selectionKey ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
|
||||||
|
@ -136,6 +136,7 @@ const Root: ParentComponent = (props) => {
|
||||||
yield iterator.currentNode as HTMLElement;
|
yield iterator.currentNode as HTMLElement;
|
||||||
}
|
}
|
||||||
})()));
|
})()));
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(entries => {
|
const observer = new MutationObserver(entries => {
|
||||||
|
@ -191,6 +192,7 @@ const Root: ParentComponent = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div ref={setRoot} style={{ 'display': 'contents' }}>{c()}</div>;
|
return <div ref={setRoot} style={{ 'display': 'contents' }}>{c()}</div>;
|
||||||
|
// return <div ref={setRoot}>{c()}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectable = (element: HTMLElement, options: Accessor<{ value: object, key?: string }>) => {
|
export const selectable = (element: HTMLElement, options: Accessor<{ value: object, key?: string }>) => {
|
||||||
|
@ -228,14 +230,11 @@ export const selectable = (element: HTMLElement, options: Accessor<{ value: obje
|
||||||
return selection.map(n => n.dataset.selectionKey!);
|
return selection.map(n => n.dataset.selectionKey!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
element.dataset.selected = isSelected() ? 'true' : undefined;
|
element.dataset.selected = isSelected() ? 'true' : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPointerDown = (e: Event) => {
|
const onPointerDown = (e: Event) => {
|
||||||
// TODO :: find out if the cell clicked is editable and early exit after that
|
|
||||||
|
|
||||||
const [latest, setLatest] = internal.latest
|
const [latest, setLatest] = internal.latest
|
||||||
const [modifier] = internal.modifier
|
const [modifier] = internal.modifier
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mutated {
|
:is(details, div):has(.mutated) > :is(summary, span:has(.mutated)) {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { children, Component, createEffect, createMemo, createResource, createSignal, createUniqueId, For, onMount, ParentProps, Show } from "solid-js";
|
import { Accessor, children, Component, createEffect, createMemo, createResource, createSignal, createUniqueId, For, onMount, ParentProps, Setter, Show } from "solid-js";
|
||||||
import { filter, MutarionKind, splitAt } from "~/utilities";
|
import { filter, MutarionKind, Mutation, splitAt } from "~/utilities";
|
||||||
import { Sidebar } from "~/components/sidebar";
|
import { Sidebar } from "~/components/sidebar";
|
||||||
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree, FileEntry, Entry } from "~/components/filetree";
|
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree, FileEntry, Entry } from "~/components/filetree";
|
||||||
import { Menu } from "~/features/menu";
|
import { Menu } from "~/features/menu";
|
||||||
|
@ -50,26 +50,110 @@ const findFile = (folder: FolderEntry, id: string) => {
|
||||||
return breadthFirstTraverse(folder).find((entry): entry is { path: string[] } & FileEntry => entry.kind === 'file' && entry.id === id);
|
return breadthFirstTraverse(folder).find((entry): entry is { path: string[] } & FileEntry => entry.kind === 'file' && entry.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Entries extends Map<string, Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { }
|
||||||
|
|
||||||
|
interface ContentTabType {
|
||||||
|
handle: FileSystemDirectoryHandle;
|
||||||
|
readonly api: Accessor<GridApi | undefined>;
|
||||||
|
readonly setApi: Setter<GridApi | undefined>;
|
||||||
|
readonly entries: Accessor<Entries>;
|
||||||
|
readonly setEntries: Setter<Entries>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Edit(props: ParentProps) {
|
export default function Edit(props: ParentProps) {
|
||||||
const filesContext = useFiles();
|
const filesContext = useFiles();
|
||||||
const [root, { refetch: getRoot, mutate: updateRoot }] = createResource(() => filesContext?.get('__root__'));
|
const [root, { refetch: getRoot, mutate: updateRoot }] = createResource(() => filesContext?.get('__root__'));
|
||||||
const [tabs, { refetch: getTabs }] = createResource<FileSystemDirectoryHandle[]>(async () => (await filesContext?.list()) ?? [], { initialValue: [], ssrLoadFrom: 'initial' });
|
const [tabs, { refetch: getTabs }] = createResource<ContentTabType[]>(async () => {
|
||||||
|
const handles = (await filesContext?.list()) ?? [];
|
||||||
|
|
||||||
|
return await Promise.all(handles.map(handle => {
|
||||||
|
const [api, setApi] = createSignal<GridApi>();
|
||||||
|
const [entries, setEntries] = createSignal<Entries>(new Map());
|
||||||
|
const files = handle.entries()
|
||||||
|
|
||||||
|
return ({ handle, api, setApi, entries, setEntries });
|
||||||
|
}));
|
||||||
|
}, { initialValue: [], ssrLoadFrom: 'initial' });
|
||||||
|
const [active, setActive] = createSignal<string>();
|
||||||
|
const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map());
|
||||||
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
||||||
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>>(new Map);
|
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>>(new Map);
|
||||||
const [api, setApi] = createSignal<GridApi>();
|
|
||||||
|
|
||||||
const mutatedFiles = createMemo(() => {
|
const tab = createMemo(() => {
|
||||||
const mutations = api()?.mutations() ?? [];
|
const name = active();
|
||||||
const files = entries();
|
return tabs().find(t => t.handle.name === name);
|
||||||
|
});
|
||||||
|
const api = createMemo(() => tab()?.api());
|
||||||
|
|
||||||
return new Set(mutations
|
const mutations = createMemo<(Mutation & { file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => {
|
||||||
.map(mutation => {
|
const entries = tab.entries();
|
||||||
const [key, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
const mutations = tab.api()?.mutations() ?? [];
|
||||||
|
|
||||||
return files.get(key)?.[lang]?.id;
|
return mutations.map(m => {
|
||||||
})
|
const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.'));
|
||||||
.filter(file => file !== undefined)
|
|
||||||
|
return { ...m, key, file: entries.get(key)?.[lang] };
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mutatedFiles = createMemo(() =>
|
||||||
|
new Set((mutations()).map(({ file }) => file).filter(file => file !== undefined))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mutatedData = createMemo(() => {
|
||||||
|
const muts = mutations();
|
||||||
|
const files = contents();
|
||||||
|
const entries = mutatedFiles().values();
|
||||||
|
|
||||||
|
if (muts.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedByFileId = Object.groupBy(muts, m => m.file?.id ?? 'undefined');
|
||||||
|
|
||||||
|
return entries.map(({ id, handle }) => {
|
||||||
|
const existing = new Map(files.get(id)!);
|
||||||
|
const mutations = groupedByFileId[id]!;
|
||||||
|
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
switch (mutation.kind) {
|
||||||
|
case MutarionKind.Delete: {
|
||||||
|
existing.delete(mutation.key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case MutarionKind.Update:
|
||||||
|
case MutarionKind.Create: {
|
||||||
|
existing.set(mutation.key, mutation.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
handle,
|
||||||
|
existing.entries().reduce((aggregate, [key, value]) => {
|
||||||
|
let obj = aggregate;
|
||||||
|
const [k, lastPart] = splitAt(key, key.lastIndexOf('.'));
|
||||||
|
|
||||||
|
for (const part of k.split('.')) {
|
||||||
|
if (!Object.hasOwn(obj, part)) {
|
||||||
|
obj[part] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
obj = obj[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
obj[lastPart] = value;
|
||||||
|
|
||||||
|
return aggregate;
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
] as const;
|
||||||
|
}).toArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log(mutatedData());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
|
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
|
||||||
|
@ -78,12 +162,18 @@ export default function Edit(props: ParentProps) {
|
||||||
getTabs();
|
getTabs();
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(() => {
|
||||||
const directory = root();
|
const directory = root();
|
||||||
|
|
||||||
if (root.state === 'ready' && directory?.kind === 'directory') {
|
if (root.state === 'ready' && directory?.kind === 'directory') {
|
||||||
|
|
||||||
|
(async () => {
|
||||||
const contents = await Array.fromAsync(walk(directory));
|
const contents = await Array.fromAsync(walk(directory));
|
||||||
const languages = new Set(contents.map(c => c.lang));
|
|
||||||
|
console.log(contents);
|
||||||
|
|
||||||
|
setContents(new Map(contents.map(({ id, entries }) => [id, entries] as const)))
|
||||||
|
|
||||||
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
|
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
|
||||||
|
|
||||||
const merged = contents.reduce((aggregate, { id, handle, path, lang, entries }) => {
|
const merged = contents.reduce((aggregate, { id, handle, path, lang, entries }) => {
|
||||||
|
@ -100,8 +190,9 @@ export default function Edit(props: ParentProps) {
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
||||||
|
|
||||||
setFiles({ name: directory.name, id: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) });
|
setFiles({ name: directory.name, id: '', kind: 'folder', handle: directory, entries: await Array.fromAsync(fileTreeWalk(directory)) });
|
||||||
setEntries(merged);
|
setEntries(merged);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -131,63 +222,51 @@ export default function Edit(props: ParentProps) {
|
||||||
updateRoot(directory);
|
updateRoot(directory);
|
||||||
}),
|
}),
|
||||||
save: createCommand('save', async () => {
|
save: createCommand('save', async () => {
|
||||||
const mutations = api()?.mutations() ?? [];
|
const results = await Promise.allSettled(mutatedData().map(async ([handle, data]) => {
|
||||||
|
const stream = await handle.createWritable({ keepExistingData: false });
|
||||||
|
|
||||||
if (mutations.length === 0) {
|
stream.write(JSON.stringify(data, null, 4));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = api()?.rows() ?? {};
|
|
||||||
const _entries = entries();
|
|
||||||
|
|
||||||
// Cases we can encounter:
|
|
||||||
// | | file extsis | no existing file |
|
|
||||||
// |---------|---------------------------|----------------------!
|
|
||||||
// | created | insert new key into file | create new file |
|
|
||||||
// | updated | update value | create new file (*1) |
|
|
||||||
// | deleted | remove key from file (*3) | no-op/skip (*2)(*3) |
|
|
||||||
//
|
|
||||||
// 1) This can happen if the key already exists in another language (so when adding a new language for example).
|
|
||||||
// 2) The same as with 1, when you delete a key, and there are not files for each language, then this is a valid case.
|
|
||||||
// 3) When a file has 0 keys, we can remove it.
|
|
||||||
|
|
||||||
const fileMutations = await Promise.all(mutations.map(async (mutation) => {
|
|
||||||
const [k, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
|
||||||
const entry = _entries.get(k);
|
|
||||||
const localEntry = entry?.[lang];
|
|
||||||
|
|
||||||
if (!localEntry) {
|
|
||||||
throw new Error('invalid edge case???');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createNewFile = async () => {
|
|
||||||
const [, alternativeLocalEntry] = Object.entries(entry).find(([l, e]) => l !== lang && e.id !== undefined) ?? [];
|
|
||||||
const { directory, path } = alternativeLocalEntry ? findFile(tree(), alternativeLocalEntry.id) ?? {} : {};
|
|
||||||
|
|
||||||
const handle = await window.showSaveFilePicker({
|
|
||||||
suggestedName: `${lang}.json`,
|
|
||||||
startIn: directory,
|
|
||||||
excludeAcceptAllOption: true,
|
|
||||||
types: [
|
|
||||||
{ accept: { 'application/json': ['.json'] }, description: 'JSON' },
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO :: patch the tree with this new entry
|
|
||||||
// console.log(localEntry, tree());
|
|
||||||
|
|
||||||
return { handle, path };
|
|
||||||
};
|
|
||||||
|
|
||||||
const { handle, path } = findFile(tree(), localEntry.id) ?? (mutation.kind !== MutarionKind.Delete ? await createNewFile() : {});
|
|
||||||
const id = await handle?.getUniqueId();
|
|
||||||
const key = path ? k.slice(path.join('.').length + 1) : k;
|
|
||||||
const value = rows[k][lang];
|
|
||||||
|
|
||||||
return { action: mutation.kind, key, id, value, handle };
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(rows, entries(), Object.groupBy(fileMutations, m => m.id ?? 'undefined'))
|
console.log(results);
|
||||||
|
|
||||||
|
// const fileMutations = await Promise.all(mutations.map(async (mutation) => {
|
||||||
|
// const [k, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
||||||
|
// const entry = _entries.get(k);
|
||||||
|
// const localEntry = entry?.[lang];
|
||||||
|
|
||||||
|
// if (!localEntry) {
|
||||||
|
// throw new Error('invalid edge case???');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const createNewFile = async () => {
|
||||||
|
// const [, alternativeLocalEntry] = Object.entries(entry).find(([l, e]) => l !== lang && e.id !== undefined) ?? [];
|
||||||
|
// const { directory, path } = alternativeLocalEntry ? findFile(tree(), alternativeLocalEntry.id) ?? {} : {};
|
||||||
|
|
||||||
|
// const handle = await window.showSaveFilePicker({
|
||||||
|
// suggestedName: `${lang}.json`,
|
||||||
|
// startIn: directory,
|
||||||
|
// excludeAcceptAllOption: true,
|
||||||
|
// types: [
|
||||||
|
// { accept: { 'application/json': ['.json'] }, description: 'JSON' },
|
||||||
|
// ]
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // TODO :: patch the tree with this new entry
|
||||||
|
// // console.log(localEntry, tree());
|
||||||
|
|
||||||
|
// return { handle, path };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const { handle, path } = findFile(tree(), localEntry.id) ?? (mutation.kind !== MutarionKind.Delete ? await createNewFile() : {});
|
||||||
|
// const id = await handle?.getUniqueId();
|
||||||
|
// const key = path ? k.slice(path.join('.').length + 1) : k;
|
||||||
|
// const value = rows[k][lang];
|
||||||
|
|
||||||
|
// return { action: mutation.kind, key, id, value, handle };
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// console.log(rows, entries(), Object.groupBy(fileMutations, m => m.id ?? 'undefined'))
|
||||||
}, { key: 's', modifier: Modifier.Control }),
|
}, { key: 's', modifier: Modifier.Control }),
|
||||||
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
|
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
|
||||||
console.log('save as ...', handle);
|
console.log('save as ...', handle);
|
||||||
|
@ -209,6 +288,9 @@ export default function Edit(props: ParentProps) {
|
||||||
clearSelection: createCommand('clear selection', () => {
|
clearSelection: createCommand('clear selection', () => {
|
||||||
api()?.clear();
|
api()?.clear();
|
||||||
}),
|
}),
|
||||||
|
delete: createCommand('delete selected items', () => {
|
||||||
|
console.log(api()?.selection())
|
||||||
|
}, { key: 'delete', modifier: Modifier.None }),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
return <div class={css.root}>
|
return <div class={css.root}>
|
||||||
|
@ -226,7 +308,15 @@ export default function Edit(props: ParentProps) {
|
||||||
<Menu.Item command={commands.save} />
|
<Menu.Item command={commands.save} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item command={noop.withLabel('edit')} />
|
<Menu.Item label="edit">
|
||||||
|
<Menu.Item command={noop.withLabel('insert new key')} />
|
||||||
|
|
||||||
|
<Menu.Item command={noop.withLabel('insert new language')} />
|
||||||
|
|
||||||
|
<Menu.Separator />
|
||||||
|
|
||||||
|
<Menu.Item command={commands.delete} />
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item label="selection">
|
<Menu.Item label="selection">
|
||||||
<Menu.Item command={commands.selectAll} />
|
<Menu.Item command={commands.selectAll} />
|
||||||
|
@ -247,31 +337,41 @@ export default function Edit(props: ParentProps) {
|
||||||
}}>{folder().name}</span>;
|
}}>{folder().name}</span>;
|
||||||
},
|
},
|
||||||
file => {
|
file => {
|
||||||
const mutated = createMemo(() => mutatedFiles().has(file().id));
|
const mutated = createMemo(() => mutatedFiles().values().find(({ id }) => id === file().id) !== undefined);
|
||||||
|
|
||||||
return <Context.Handle classList={{ [css.mutated]: mutated() }}>{file().name}</Context.Handle>;
|
return <Context.Handle classList={{ [css.mutated]: mutated() }} onDblClick={() => {
|
||||||
|
const folder = file().directory;
|
||||||
|
filesContext?.set(folder.name, folder);
|
||||||
|
getTabs();
|
||||||
|
}}>{file().name}</Context.Handle>;
|
||||||
},
|
},
|
||||||
] as const}</Tree>
|
] as const}</Tree>
|
||||||
</Show>
|
</Show>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs active={setActive}>
|
||||||
<For each={tabs()}>{
|
<For each={tabs()}>{
|
||||||
directory => <Tab id={createUniqueId()} label={directory.name}>{'some text in front'}<Kaas directory={directory} /></Tab>
|
({ handle, setApi, setEntries }) => <Tab id={handle.name} label={handle.name}><Content directory={handle} api={setApi} entries={setEntries} /></Tab>
|
||||||
}</For>
|
}</For>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Context.Root>
|
</Context.Root>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const Kaas: Component<{ directory: FileSystemDirectoryHandle }> = (props) => {
|
const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<GridApi | undefined>, entries?: Setter<Entries> }> = (props) => {
|
||||||
const filesContext = useFiles();
|
const [entries, setEntries] = createSignal<Entries>(new Map());
|
||||||
const [root, { mutate, refetch }] = createResource(() => filesContext?.get('root'));
|
|
||||||
const [columns, setColumns] = createSignal<string[]>([]);
|
const [columns, setColumns] = createSignal<string[]>([]);
|
||||||
const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
|
const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
|
||||||
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>>(new Map);
|
|
||||||
const [api, setApi] = createSignal<GridApi>();
|
const [api, setApi] = createSignal<GridApi>();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.entries?.(entries());
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.api?.(api());
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const directory = props.directory;
|
const directory = props.directory;
|
||||||
|
|
||||||
|
@ -307,13 +407,10 @@ const Kaas: Component<{ directory: FileSystemDirectoryHandle }> = (props) => {
|
||||||
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
||||||
|
|
||||||
setColumns(['key', ...languages]);
|
setColumns(['key', ...languages]);
|
||||||
|
setEntries(merged);
|
||||||
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
|
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
// console.log(columns(), rows());
|
|
||||||
});
|
|
||||||
|
|
||||||
return <Grid columns={columns()} rows={rows()} api={setApi} />;
|
return <Grid columns={columns()} rows={rows()} api={setApi} />;
|
||||||
};
|
};
|
Loading…
Add table
Add a link
Reference in a new issue