fix issues with tabs

This commit is contained in:
Chris Kruining 2024-10-29 16:20:48 +01:00
parent b27abe928d
commit addc6bfb11
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
14 changed files with 210 additions and 137 deletions

View file

@ -10,7 +10,6 @@
} }
], ],
"start_url": ".", "start_url": ".",
"display": "standalone",
"display_override": [ "display_override": [
"window-controls-overlay" "window-controls-overlay"
], ],

View file

@ -49,6 +49,7 @@ body {
grid: 100% / 100%; grid: 100% / 100%;
background-color: var(--surface-1); background-color: var(--surface-1);
color: var(--text-2);
margin: 0; margin: 0;
@ -68,7 +69,7 @@ a {
} }
h1 { h1 {
color: var(--primary); color: var(--text-1);
text-transform: uppercase; text-transform: uppercase;
font-size: 4rem; font-size: 4rem;
font-weight: 100; font-weight: 100;

View file

@ -1,4 +1,22 @@
.picker { .picker {
border: none; display: flex;
background-color: var(--surface-3); flex-flow: row;
align-items: center;
border: 1px solid transparent;
border-radius: var(--radii-m);
padding: var(--padding-s);
& select {
border: none;
background-color: var(--surface-3);
border-radius: var(--radii-m);
&:focus {
outline: none;
}
}
&:has(:focus-visible) {
border-color: var(--info);
}
} }

View file

@ -1,5 +1,6 @@
import { Accessor, Component, createEffect, createSignal, For, Setter } from "solid-js"; import { Accessor, Component, createEffect, createSignal, For, Setter } from "solid-js";
import css from './colorschemepicker.module.css'; import css from './colorschemepicker.module.css';
import { CgDarkMode } from "solid-icons/cg";
export enum ColorScheme { export enum ColorScheme {
Auto = 'light dark', Auto = 'light dark',
@ -31,13 +32,17 @@ export const ColorSchemePicker: Component<ColorSchemePickerProps> = (props) => {
setter(currentValue); setter(currentValue);
}); });
return <select class={css.picker} name="color-scheme-picker" value={value()} onInput={(e) => { return <label class={css.picker}>
if (e.target.value !== value()) { <CgDarkMode />
setValue(e.target.value as any);
} <select name="color-scheme-picker" value={value()} onInput={(e) => {
}}> if (e.target.value !== value()) {
<For each={colorSchemeEntries}>{ setValue(e.target.value as any);
([value, label]) => <option value={value}>{label}</option> }
}</For> }}>
</select>; <For each={colorSchemeEntries}>{
([value, label]) => <option value={value}>{label}</option>
}</For>
</select>
</label>;
}; };

View file

@ -1,19 +1,14 @@
import { Accessor, children, createContext, createEffect, createMemo, createSignal, For, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js"; import { Accessor, children, createContext, createEffect, createMemo, createSignal, For, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js";
import { IoCloseCircleOutline } from "solid-icons/io"; import { IoCloseCircleOutline } from "solid-icons/io";
import css from "./tabs.module.css"; import css from "./tabs.module.css";
import { Command, CommandType, commandArguments, noop, useCommands } from "~/features/command"; import { Command, CommandType, noop, useCommands } from "~/features/command";
commandArguments;
interface TabsContextType { interface TabsContextType {
register(id: string, label: string, options?: Partial<TabOptions>): Accessor<boolean>; activate(id: string | undefined): void;
isActive(id: string): Accessor<boolean>;
readonly onClose: Accessor<CommandType<[string]> | undefined> readonly onClose: Accessor<CommandType<[string]> | undefined>
} }
interface TabOptions {
closable: boolean;
}
const TabsContext = createContext<TabsContextType>(); const TabsContext = createContext<TabsContextType>();
const useTabs = () => { const useTabs = () => {
@ -29,29 +24,39 @@ const useTabs = () => {
export const Tabs: ParentComponent<{ active?: Setter<string | undefined>, onClose?: CommandType<[string]> }> = (props) => { export const Tabs: ParentComponent<{ active?: Setter<string | undefined>, onClose?: CommandType<[string]> }> = (props) => {
const commandsContext = useCommands(); const commandsContext = useCommands();
const [active, setActive] = createSignal<string | undefined>(undefined); const [active, setActive] = createSignal<string | undefined>(undefined);
const [tabs, setTabs] = createSignal<Map<string, { label: string, options: Partial<TabOptions> }>>(new Map());
createEffect(() => { createEffect(() => {
props.active?.(active()); props.active?.(active());
}); });
createEffect(() => {
setActive(tabs().keys().toArray().at(-1));
});
const ctx = { const ctx = {
register(id: string, label: string, options: Partial<TabOptions>) { activate(id: string) {
setTabs(tabs => { setActive(id);
tabs.set(id, { label, options }); },
return new Map(tabs);
});
isActive(id: string) {
return createMemo(() => active() === id); return createMemo(() => active() === id);
}, },
onClose: createMemo(() => props.onClose), onClose: createMemo(() => props.onClose),
}; };
return <TabsContext.Provider value={ctx}>
<_Tabs active={active()} onClose={props.onClose}>{props.children}</_Tabs>
</TabsContext.Provider >;
}
const _Tabs: ParentComponent<{ active: string | undefined, onClose?: CommandType<[string]> }> = (props) => {
const commandsContext = useCommands();
const tabsContext = useTabs();
const resolved = children(() => props.children);
const tabs = createMemo(() => resolved.toArray().filter(c => c instanceof HTMLElement).map(({ id, dataset }, i) => ({ id, label: dataset.tabLabel, options: { closable: dataset.tabClosable } })));
createEffect(() => {
tabsContext.activate(tabs().at(-1)?.id);
});
const onClose = (e: Event) => { const onClose = (e: Event) => {
if (!commandsContext || !props.onClose) { if (!commandsContext || !props.onClose) {
return; return;
@ -60,33 +65,44 @@ export const Tabs: ParentComponent<{ active?: Setter<string | undefined>, onClos
return commandsContext.execute(props.onClose, e); return commandsContext.execute(props.onClose, e);
}; };
return <TabsContext.Provider value={ctx}> return <div class={css.tabs}>
<div class={css.tabs}> <header>
<header> <For each={tabs()}>{
<For each={tabs().entries().toArray()}>{ ({ id, label, options: { closable = false } }) => <Command.Context for={props.onClose} with={[id]}>
([id, { label, options: { closable = false } }]) => <Command.Context for={props.onClose} with={[id]}> <span class={css.handle} classList={{ [css.active]: props.active === id }}>
<span class={css.handle} classList={{ [css.active]: active() === id }}> <button onpointerdown={() => tabsContext.activate(id)}>{label}</button>
<button onpointerdown={() => setActive(id)}>{label}</button> <Show when={closable}>
<Show when={closable}> <button onPointerDown={onClose}> <IoCloseCircleOutline /></button>
<button onPointerDown={onClose}> <IoCloseCircleOutline /></button> </Show>
</Show> </span>
</span> </Command.Context>
</Command.Context> }</For>
}</For> </header>
</header>
{props.children} {resolved()}
</div> </div>;
</TabsContext.Provider >; };
}
export const Tab: ParentComponent<{ id: string, label: string, closable?: boolean }> = (props) => { export const Tab: ParentComponent<{ id: string, label: string, closable?: boolean }> = (props) => {
const context = useTabs(); const context = useTabs();
const isActive = context.register(props.id, props.label, {
closable: props.closable ?? false
});
const resolved = children(() => props.children); const resolved = children(() => props.children);
const isActive = context.isActive(props.id);
const [ref, setRef] = createSignal();
return <Show when={isActive()}><Command.Context for={context.onClose()} with={[props.id]}>{resolved()}</Command.Context></Show>; // const isActive = context.register(props.id, props.label, {
// closable: props.closable ?? false,
// ref: ref,
// });
return <div
ref={setRef()}
id={props.id}
data-tab-label={props.label}
data-tab-closable={props.closable}
style="dispay: contents;"
>
<Show when={isActive()}>
<Command.Context for={context.onClose() ?? noop} with={[props.id]}>{resolved()}</Command.Context>
</Show>
</div>;
} }

View file

@ -31,24 +31,28 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
}, },
execute<T extends any[] = any[]>(command: CommandType<T>, event: Event): boolean | undefined { execute<T extends any[] = any[]>(command: CommandType<T>, event: Event): boolean | undefined {
const contexts = contextualArguments.get(command); const args = ((): T => {
if (contexts === undefined) { const contexts = contextualArguments.get(command);
return;
}
const element = event.composedPath().find(el => contexts.has(el)); if (contexts === undefined) {
return [] as any;
}
if (element === undefined) { const element = event.composedPath().find(el => contexts.has(el));
return;
} if (element === undefined) {
return [] as any;
}
const args = contexts.get(element)! as Accessor<T>;
return args();
})();
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const args = contexts.get(element)! as Accessor<T>; command(...args);
command(...args());
return false; return false;
}, },
@ -159,17 +163,6 @@ export const createCommand = <TArgs extends any[] = []>(label: string, command:
}); });
}; };
export const commandArguments = <T extends any[] = any[]>(element: Element, commandAndArgs: Accessor<[CommandType<T>, T]>) => {
const ctx = useContext(CommandContext);
const args = createMemo(() => commandAndArgs()[1]);
if (!ctx) {
return;
}
ctx.addContextualArguments(commandAndArgs()[0], element, args);
}
export const noop = Object.defineProperties(createCommand('noop', () => { }), { export const noop = Object.defineProperties(createCommand('noop', () => { }), {
withLabel: { withLabel: {
value(label: string) { value(label: string) {
@ -180,12 +173,4 @@ export const noop = Object.defineProperties(createCommand('noop', () => { }), {
}, },
}) as CommandType & { withLabel(label: string): CommandType }; }) as CommandType & { withLabel(label: string): CommandType };
declare module "solid-js" {
namespace JSX {
interface Directives {
commandArguments<T extends any[] = any[]>(): [CommandType<T>, T];
}
}
}
export { Context } from './contextMenu'; export { Context } from './contextMenu';

View file

@ -4,6 +4,8 @@ import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import * as json from './parser/json'; import * as json from './parser/json';
const ROOT = '__root__';
interface FileEntity { interface FileEntity {
key: string; key: string;
handle: FileSystemDirectoryHandle; handle: FileSystemDirectoryHandle;
@ -25,7 +27,9 @@ interface InternalFilesContextType {
interface FilesContextType { interface FilesContextType {
readonly files: Accessor<FileEntity[]>, readonly files: Accessor<FileEntity[]>,
readonly root: Accessor<FileSystemDirectoryHandle | undefined>,
open(directory: FileSystemDirectoryHandle): void;
get(key: string): Accessor<FileSystemDirectoryHandle | undefined> get(key: string): Accessor<FileSystemDirectoryHandle | undefined>
set(key: string, handle: FileSystemDirectoryHandle): Promise<void>; set(key: string, handle: FileSystemDirectoryHandle): Promise<void>;
remove(key: string): Promise<void>; remove(key: string): Promise<void>;
@ -42,10 +46,16 @@ const clientContext = (): InternalFilesContextType => {
return { return {
onChange(hook: (key: string, handle: FileSystemDirectoryHandle) => any) { onChange(hook: (key: string, handle: FileSystemDirectoryHandle) => any) {
const callHook = (key: string, handle: FileSystemDirectoryHandle) => setTimeout(() => hook(key, handle), 1); const callHook = (key: string, handle: FileSystemDirectoryHandle) => {
if (!key || key === ROOT) {
return;
}
setTimeout(() => hook(key, handle), 1);
};
db.files.hook('creating', (_: string, { key, handle }: FileEntity) => { callHook(key, handle); }); db.files.hook('creating', (_: string, { key, handle }: FileEntity) => { callHook(key, handle); });
db.files.hook('deleting', (_: string, { key, handle }: FileEntity) => callHook(key, handle)); db.files.hook('deleting', (_: string, { key, handle }: FileEntity = { key: undefined!, handle: undefined! }) => callHook(key, handle));
db.files.hook('updating', (_1: Object, _2: string, { key, handle }: FileEntity) => callHook(key, handle)); db.files.hook('updating', (_1: Object, _2: string, { key, handle }: FileEntity) => callHook(key, handle));
}, },
@ -59,13 +69,13 @@ const clientContext = (): InternalFilesContextType => {
return (await db.files.delete(key)); return (await db.files.delete(key));
}, },
async keys() { async keys() {
return (await db.files.toArray()).map(f => f.key); return (await db.files.where('key').notEqual(ROOT).toArray()).map(f => f.key);
}, },
async entries() { async entries() {
return await db.files.toArray(); return await db.files.where('key').notEqual(ROOT).toArray();
}, },
async list() { async list() {
const files = await db.files.toArray(); const files = await db.files.where('key').notEqual(ROOT).toArray();
return files.map(f => f.handle) return files.map(f => f.handle)
}, },
@ -99,27 +109,35 @@ const serverContext = (): InternalFilesContextType => ({
export const FilesProvider: ParentComponent = (props) => { export const FilesProvider: ParentComponent = (props) => {
const internal = isServer ? serverContext() : clientContext(); const internal = isServer ? serverContext() : clientContext();
const [state, setState] = createStore<{ files: FileEntity[] }>({ files: [] }); const [state, setState] = createStore<{ openedFiles: FileEntity[], root: FileSystemDirectoryHandle | undefined }>({ openedFiles: [], root: undefined });
const updateFilesInState = async () => { internal.onChange(async () => {
const entities = await internal.entries(); setState('openedFiles', await internal.entries());
setState('files', entities);
};
internal.onChange((key: string, handle: FileSystemDirectoryHandle) => {
updateFilesInState();
}); });
onMount(() => { onMount(() => {
updateFilesInState(); (async () => {
const [root, files] = await Promise.all([
internal.get(ROOT),
internal.entries(),
]);
setState('root', root);
setState('openedFiles', files);
})();
}); });
const context: FilesContextType = { const context: FilesContextType = {
files: createMemo(() => state.files), files: createMemo(() => state.openedFiles),
root: createMemo(() => state.root),
open(directory: FileSystemDirectoryHandle) {
setState('root', directory);
internal.set(ROOT, directory);
},
get(key: string): Accessor<FileSystemDirectoryHandle | undefined> { get(key: string): Accessor<FileSystemDirectoryHandle | undefined> {
return createMemo(() => state.files.find(entity => entity.key === key)?.handle); return createMemo(() => state.openedFiles.find(entity => entity.key === key)?.handle);
}, },
async set(key: string, handle: FileSystemDirectoryHandle) { async set(key: string, handle: FileSystemDirectoryHandle) {

View file

@ -1,7 +1,7 @@
import { Accessor, Component, For, JSX, Match, ParentComponent, Setter, Show, Switch, 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, useCommands } from "../command";
import css from "./index.module.css"; import css from "./index.module.css";
export interface MenuContextType { export interface MenuContextType {
@ -35,7 +35,7 @@ const MenuContext = createContext<MenuContextType>();
export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (props) => { export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (props) => {
const [ref, setRef] = createSignal<Node | undefined>(); const [ref, setRef] = createSignal<Node | undefined>();
const [store, setStore] = createStore<{ items: Record<string, Item | ItemWithChildren> }>({ items: {} }); const [store, setStore] = createStore<{ items: Map<string, Item | ItemWithChildren> }>({ items: new Map });
const ctx = { const ctx = {
ref, ref,
@ -43,19 +43,19 @@ export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (prop
addItems(items: (Item | ItemWithChildren)[]) { addItems(items: (Item | ItemWithChildren)[]) {
return setStore('items', values => { return setStore('items', values => {
for (const item of items) { for (const item of items) {
values[item.id] = item; values.set(item.id, item);
} }
return values; return new Map(values.entries());
}) })
}, },
items() { items() {
return Object.values(store.items); return store.items.values();
}, },
commands() { commands() {
return Object.values(store.items) return store.items.values()
.map(item => item.kind === 'node' ? item.children.filter(c => c.kind === 'leaf').map(c => c.command) : item.command) .flatMap(item => item.kind === 'node' ? item.children.filter(c => c.kind === 'leaf').map(c => c.command) : [item.command])
.flat() .toArray()
.concat(props.commands ?? []); .concat(props.commands ?? []);
}, },
}; };
@ -100,11 +100,12 @@ const Separator: Component = (props) => {
} }
const Root: ParentComponent<{}> = (props) => { const Root: ParentComponent<{}> = (props) => {
const menu = useMenu(); const menuContext = useMenu();
const commandContext = useCommands();
const [current, setCurrent] = createSignal<HTMLElement>(); const [current, setCurrent] = createSignal<HTMLElement>();
const items = children(() => props.children).toArray() as unknown as (Item | ItemWithChildren)[]; const items = children(() => props.children).toArray() as unknown as (Item | ItemWithChildren)[];
menu.addItems(items) menuContext.addItems(items)
const close = () => { const close = () => {
const el = current(); const el = current();
@ -118,10 +119,10 @@ const Root: ParentComponent<{}> = (props) => {
const onExecute = (command?: CommandType) => { const onExecute = (command?: CommandType) => {
return command return command
? async () => { ? (e: Event) => {
await command?.();
close(); close();
return commandContext?.execute(command, e);
} }
: () => { } : () => { }
}; };
@ -132,7 +133,7 @@ const Root: ParentComponent<{}> = (props) => {
</button> </button>
}; };
return <Portal mount={menu.ref()}> return <Portal mount={menuContext.ref()}>
<For each={items}>{ <For each={items}>{
item => <Switch> item => <Switch>
<Match when={item.kind === 'node' ? item as ItemWithChildren : undefined}>{ <Match when={item.kind === 'node' ? item as ItemWithChildren : undefined}>{
@ -349,4 +350,4 @@ declare module "solid-js" {
anchor?: string | undefined; anchor?: string | undefined;
} }
} }
} }

View file

@ -1,10 +1,8 @@
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
export default function Home() { export default function Home() {
return ( return <>
<main> <Title>About</Title>
<Title>About</Title> <h1>About</h1>
<h1>About</h1> </>;
</main>
);
} }

View file

@ -23,4 +23,13 @@
content: ' •'; content: ' •';
} }
} }
}
.blank {
display: grid;
grid: 100% / 100%;
inline-size: 100%;
block-size: 100%;
place-items: center;
} }

View file

@ -4,7 +4,7 @@ import { Sidebar } from "~/components/sidebar";
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree";
import { Menu } from "~/features/menu"; import { Menu } from "~/features/menu";
import { Grid, load, useFiles } from "~/features/file"; import { Grid, load, useFiles } from "~/features/file";
import { Command, Context, createCommand, Modifier, noop, useCommands } from "~/features/command"; import { Command, CommandType, Context, createCommand, Modifier, noop, useCommands } from "~/features/command";
import { GridApi } from "~/features/file/grid"; import { GridApi } from "~/features/file/grid";
import { Tab, Tabs } from "~/components/tabs"; import { Tab, Tabs } from "~/components/tabs";
import css from "./edit.module.css"; import css from "./edit.module.css";
@ -35,21 +35,20 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []):
} }
}; };
const open = createCommand('open folder', async () => {
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
useFiles().set('__root__', directory);
}, { key: 'o', modifier: Modifier.Control });
interface Entries extends Map<string, Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { } interface Entries extends Map<string, Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { }
export default function Edit(props: ParentProps) { export default function Edit(props: ParentProps) {
const filesContext = useFiles(); const filesContext = useFiles();
const root = filesContext.get('__root__'); const open = createCommand('open folder', async () => {
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
filesContext.open(directory);
}, { key: 'o', modifier: Modifier.Control });
return <Context.Root commands={[open]}> return <Context.Root commands={[open]}>
<Show when={root()} fallback={<button onpointerdown={() => open()}>open a folder</button>}>{ <Show when={filesContext.root()} fallback={<Blank open={open} />}>{
root => <Editor root={root()} /> root => <Editor root={root()} />
}</Show> }</Show>
</Context.Root>; </Context.Root>;
@ -58,11 +57,11 @@ export default function Edit(props: ParentProps) {
const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
const filesContext = useFiles(); const filesContext = useFiles();
const tabs = createMemo(() => filesContext.files().map(({ handle }) => { const tabs = createMemo(() => filesContext.files().map(({ key, handle }) => {
const [api, setApi] = createSignal<GridApi>(); const [api, setApi] = createSignal<GridApi>();
const [entries, setEntries] = createSignal<Entries>(new Map()); const [entries, setEntries] = createSignal<Entries>(new Map());
return ({ handle, api, setApi, entries, setEntries }); return ({ key, handle, api, setApi, entries, setEntries });
})); }));
const [active, setActive] = createSignal<string>(); const [active, setActive] = createSignal<string>();
const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map()); const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map());
@ -247,8 +246,8 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
<Tabs active={setActive} onClose={commands.closeTab}> <Tabs active={setActive} onClose={commands.closeTab}>
<For each={tabs()}>{ <For each={tabs()}>{
({ handle, setApi, setEntries }) => <Tab ({ key, handle, setApi, setEntries }) => <Tab
id={handle.name} id={key}
label={handle.name} label={handle.name}
closable closable
> >
@ -314,4 +313,10 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<Gr
}); });
return <Grid columns={columns()} rows={rows()} api={setApi} />; return <Grid columns={columns()} rows={rows()} api={setApi} />;
};
const Blank: Component<{ open: CommandType }> = (props) => {
return <div class={css.blank}>
<button onpointerdown={() => props.open()}>open a folder</button>
</div>
}; };

View file

@ -1,4 +1,13 @@
.main { .main {
display: grid; display: grid;
place-content: center; place-content: center;
gap: var(--padding-m);
ul {
display: flex;
flex-flow: column;
gap: var(--padding-s);
padding-inline-start: var(--padding-l);
margin: 0;
}
} }

View file

@ -46,7 +46,9 @@ export default function Index() {
<ul> <ul>
<li><A href="/edit">Start editing</A></li> <li><A href="/edit">Start editing</A></li>
<li><A href="/experimental">Try new features</A></li> {/* <li><A href="/experimental">Try new features</A></li> */}
<li><A href="/instructions">Read the instructions</A></li>
<li><A href="/about">About this app</A></li>
</ul> </ul>
</main> </main>
); );

View file

@ -0,0 +1,7 @@
export default function Instructions() {
return <></>;
};