diff --git a/app.config.ts b/app.config.ts index de7f831..e97501a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,3 +1,10 @@ import { defineConfig } from "@solidjs/start/config"; +import { VitePWA } from 'vite-plugin-pwa' -export default defineConfig({}); +export default defineConfig({ + vite: { + plugins: [ + VitePWA({ registerType: 'autoUpdate' }), + ] + } +}); diff --git a/bun.lockb b/bun.lockb index 6e10afa..b7e2344 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4d2d8fc..5fd20f6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "type": "module", "devDependencies": { - "@types/wicg-file-system-access": "^2023.10.5" + "@types/wicg-file-system-access": "^2023.10.5", + "vite-plugin-pwa": "^0.20.5", + "workbox-window": "^7.1.0" } } diff --git a/src/app.css b/src/app.css index 61a752b..507ad65 100644 --- a/src/app.css +++ b/src/app.css @@ -25,6 +25,10 @@ --text-l: 1.25rem; --text-xl: 1.6rem; --text-xxl: 2rem; + + --padding-s: .25em; + --padding-m: .5em; + --padding-l: 1em; } @media (prefers-color-scheme: dark) { diff --git a/src/components/filetree.module.css b/src/components/filetree.module.css index 933a142..faf656a 100644 --- a/src/components/filetree.module.css +++ b/src/components/filetree.module.css @@ -2,24 +2,29 @@ display: flex; flex-direction: column; list-style: none; - padding-inline-start: 0; & details { - & > summary::marker { - content: none; - color: var(--text-1) !important; + & > summary { + padding: var(--padding-s); + + &::marker { + content: none; + color: var(--text-1) !important; + } } - & span { - cursor: pointer; + &::details-content { + display: flex; + flex-direction: column; + list-style: none; + padding-inline-start: 1.25em; } - } - & ul { - padding-inline-start: 1.25em; } & span { + cursor: pointer; white-space: nowrap; + padding: var(--padding-s); } } \ No newline at end of file diff --git a/src/components/filetree.tsx b/src/components/filetree.tsx index 00d0a11..e6a9f33 100644 --- a/src/components/filetree.tsx +++ b/src/components/filetree.tsx @@ -1,6 +1,7 @@ -import { Accessor, Component, createSignal, For, JSX, Show } from "solid-js"; +import { Accessor, Component, createContext, createSignal, For, JSX, Show, useContext } from "solid-js"; import css from "./filetree.module.css"; import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai"; +import { SelectionProvider, selectable } from "~/features/selectable"; export interface FileEntry { name: string; @@ -38,27 +39,61 @@ export async function* walk(directory: FileSystemDirectoryHandle, filters: RegEx } } -export const Tree: Component<{ entries: Entry[], children: (file: Accessor) => JSX.Element }> = (props) => { - return +const TreeContext = createContext(); + +export const Tree: Component<{ entries: Entry[], children: (file: Accessor) => JSX.Element, open: TreeContextType['open'] }> = (props) => { + const [selection, setSelection] = createSignal(); + + // createEffect(() => { + // console.log(selection()); + // }); + + const context = { + open: props.open, + // open(file: File) { + // console.log(`open ${file.name}`) + // }, + }; + + return + +
<_Tree entries={props.entries} children={props.children} />
+
+
; +} + +const _Tree: Component<{ entries: Entry[], children: (file: Accessor) => JSX.Element }> = (props) => { + const context = useContext(TreeContext); + + return { + entry => <> + { + folder => + } + + { + file => context?.open(file().meta)}> {props.children(file)} + } + + } } const Folder: Component<{ folder: FolderEntry, children: (file: Accessor) => JSX.Element }> = (props) => { const [open, setOpen] = createSignal(false); - return
setOpen(o => !o)}> + return
setOpen(o => !o)}> }> {props.folder.name} - + <_Tree entries={props.folder.entries} children={props.children} />
; +}; + +const sort_by = (key: string) => (objA: Record, objB: Record) => { + const a = objA[key]; + const b = objB[key]; + + return Number(a < b) - Number(b < a); }; \ No newline at end of file diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index e5cfaf4..4b1b4e9 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -1,29 +1,25 @@ import { TbLayoutSidebarLeftCollapse, TbLayoutSidebarLeftExpand } from "solid-icons/tb"; -import { createMemo, createSignal, onMount, ParentComponent, Show } from "solid-js"; -import { Dynamic, Portal, render } from "solid-js/web"; +import { createMemo, createSignal, ParentComponent, Show } from "solid-js"; +import { Dynamic } from "solid-js/web"; import css from "./sidebar.module.css"; export const Sidebar: ParentComponent<{ as?: string, open?: boolean, name?: string }> = (props) => { - const [open, setOpen] = createSignal(props.open ?? true) - const cssClass = createMemo(() => open() ? css.open : css.closed); + const [open, setOpen] = createSignal(props.open ?? true); const name = createMemo(() => props.name ?? 'sidebar'); - const toggle = () => setOpen(o => !o); + return + - let ref: Element; - return - - - -
- -
-
- - {props.children} +
+ {props.children} +
}; \ No newline at end of file diff --git a/src/components/tabs.module.css b/src/components/tabs.module.css index 38d0765..a2f28ed 100644 --- a/src/components/tabs.module.css +++ b/src/components/tabs.module.css @@ -1,6 +1,10 @@ .root { display: grid; - grid-template-rows: auto 1fr; + grid: auto minmax(0, 1fr) / repeat(var(--tab-count), auto); + justify-content: start; + + inline-size: 100%; + block-size: 100%; .tab { display: contents; @@ -8,13 +12,37 @@ & > summary { grid-row: 1 / 1; + padding: var(--padding-s) var(--padding-m); + &::marker { content: none; } } &::details-content { - grid-area: 2 / 1; + grid-area: 2 / 1 / span 1 / span var(--tab-count); + display: none; + grid: 100% / 100%; + inline-size: 100%; + block-size: 100%; + + overflow: auto; + } + + &[open] { + & > summary { + background-color: var(--surface-2); + } + + &::details-content { + display: grid; + } } } +} + +@property --tab-count { + syntax: ''; + inherits: true; + initial-value: 0; } \ No newline at end of file diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx index 35dd2e2..507bc07 100644 --- a/src/components/tabs.tsx +++ b/src/components/tabs.tsx @@ -1,72 +1,21 @@ -import { Accessor, children, Component, createContext, createEffect, createMemo, createSignal, createUniqueId, For, JSX, ParentComponent, useContext } from "solid-js"; -import { createStore } from "solid-js/store"; +import { Accessor, children, createContext, createMemo, createSignal, createUniqueId, For, JSX, ParentComponent, useContext } from "solid-js"; import css from "./tabs.module.css"; -import { Portal } from "solid-js/web"; interface TabsContextType { - isActive(): boolean; -} - -interface TabsState { - tabs: TabType[]; -} - -interface TabType { - id: string; - label: string; -} - -const TabsContext = createContext(); - -export const Tabs: Component<{ children?: JSX.Element }> = (props) => { - const [state, setState] = createStore({ tabs: [] }); - - createEffect(() => { - const tabs = children(() => props.children).toArray(); - - console.log(tabs); - - setState('tabs', tabs.map(t => ({ id: t.id, label: t.getAttribute('data-label') }))) - }); - - const ctx: TabsContextType = { - isActive() { - return false; - } - }; - - return -
- { - tab => - } -
- - {props.children} -
-}; - -export const Tab: ParentComponent<{ label: string }> = (props) => { - const context = useContext(TabsContext); - - return
{props.children}
-} - -interface TabsSimpleContextType { activate(id: string): void; active: Accessor; isActive(id: string): Accessor; } -const TabsSimpleContext = createContext(); +const TabsContext = createContext(); -export const TabsSimple: ParentComponent = (props) => { +export const Tabs: ParentComponent = (props) => { const [active, setActive] = createSignal(undefined); + const numberOfTabs = createMemo(() => children(() => props.children).toArray().length); - return { return createMemo(() => active() === id); }, }}> -
+
{props.children}
- ; + ; } -export const TabSimple: ParentComponent<{ label: string }> = (props) => { +export const Tab: ParentComponent<{ label: string }> = (props) => { const id = `tab-${createUniqueId()}`; - const context = useContext(TabsSimpleContext); + const context = useContext(TabsContext); if (!context) { return undefined; diff --git a/src/features/file/grid.css b/src/features/file/grid.css index f312d29..db53694 100644 --- a/src/features/file/grid.css +++ b/src/features/file/grid.css @@ -33,7 +33,7 @@ border: 1px solid transparent; border-radius: var(--radii-m); - &:focus-within { + &:has(textarea:focus) { border-color: var(--info); } } diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 81a15e1..a3725cd 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -25,9 +25,9 @@ export interface SelectionContextType { select(key: string, select: boolean): void; } export interface GridContextType { - mutate(prop: string, lang: string, value: string): void; - add(prop: string): void; + rows: Record; selection: SelectionContextType; + mutate(prop: string, lang: string, value: string): void; } const SelectionContext = createContext(); @@ -77,9 +77,7 @@ const SelectionProvider: ParentComponent<{ rows: Map; }; const GridProvider: ParentComponent<{ rows: Map, context?: (ctx: GridContextType) => any }> = (props) => { - type Entry = { [lang: string]: { original: string, value: string } }; - - const [state, setState] = createStore<{ rows: { [prop: string]: Entry }, numberOfRows: number }>({ + const [state, setState] = createStore<{ rows: GridContextType['rows'], numberOfRows: number }>({ rows: {}, numberOfRows: 0, }); @@ -96,30 +94,27 @@ const GridProvider: ParentComponent<{ rows: Map { - console.log(state.rows.toplevel?.nl.value); - }); - const ctx: GridContextType = { + rows: state.rows, + selection: undefined!, + mutate(prop: string, lang: string, value: string) { - // setState('rows', prop, lang, ({ original }) => ({ original, value })); setState('rows', produce(rows => { rows[prop][lang].value = value; })); }, - - add(prop: string) { - - }, - - selection: undefined!, }; createEffect(() => { - console.log(ctx); props.context?.(ctx); }); + const mutated = createMemo(() => Object.values(state.rows).filter(entry => Object.values(entry).some(lang => lang.original !== lang.value))); + + createEffect(() => { + console.log('tap', mutated()); + }); + return ctx.selection = selction}> {props.children} diff --git a/src/features/file/index.tsx b/src/features/file/index.tsx index e481ae3..7633d33 100644 --- a/src/features/file/index.tsx +++ b/src/features/file/index.tsx @@ -62,7 +62,7 @@ export const FilesProvider = (props) => { return {props.children}; } -export const useFiles = () => useContext(FilesContext)!; +export const useFiles = () => useContext(FilesContext); export const load = (file: File): Promise | undefined> => { switch (file.type) { diff --git a/src/features/selectable/index.module.css b/src/features/selectable/index.module.css new file mode 100644 index 0000000..7520d87 --- /dev/null +++ b/src/features/selectable/index.module.css @@ -0,0 +1,5 @@ +.selectable { + &[data-selected="true"] { + background-color: color(from var(--info) xyz x y z / .2); + } +} \ No newline at end of file diff --git a/src/features/selectable/index.tsx b/src/features/selectable/index.tsx new file mode 100644 index 0000000..c54575c --- /dev/null +++ b/src/features/selectable/index.tsx @@ -0,0 +1,236 @@ +import { Accessor, children, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, onCleanup, onMount, ParentComponent, useContext } from "solid-js"; +import { createStore, produce } from "solid-js/store"; +import { isServer } from "solid-js/web"; +import css from "./index.module.css"; + +export interface SelectionContextType { + selection(): object[]; + select(selection: string[], options?: Partial<{ append: boolean }>): void; + selectAll(): void; + clear(): void; + isSelected(key: string): Accessor; + add(key: string, value: object, element: HTMLElement): void; +} +export type SelectionHandler = (selection: object[]) => any; + +enum Modifier { + None = 0, + Shift = 1 << 0, + Control = 1 << 1, +} + +const SelectionContext = createContext(); + +const useSelection = () => { + const context = useContext(SelectionContext); + + if (context === undefined) { + throw new Error('selection context is used outside of a provider'); + } + + return context; +}; + +interface State { + selection: string[], + data: { key: string, value: Accessor, element: HTMLElement }[] +} + +export const SelectionProvider: ParentComponent<{ selection?: SelectionHandler }> = (props) => { + const [state, setState] = createStore({ selection: [], data: [] }); + const selection = createMemo(() => state.data.filter(({ key }) => state.selection.includes(key))); + + createEffect(() => { + props.selection?.(selection().map(({ value }) => value())); + }); + + const context = { + selection, + select(selection: string[]) { + setState('selection', selection); + }, + selectAll() { + setState('selection', state.data.map(({ key }) => key)); + }, + clear() { + setState('selection', []); + }, + isSelected(key: string) { + return createMemo(() => state.selection.includes(key)); + }, + add(key: string, value: Accessor, element: HTMLElement) { + setState('data', data => [...data, { key, value, element }]); + } + }; + + return + {props.children} + ; +}; + +const Root: ParentComponent = (props) => { + const context = useSelection(); + const c = children(() => props.children); + + const [modifier, setModifier] = createSignal(Modifier.None); + const [latest, setLatest] = createSignal(); + const [root, setRoot] = createSignal(); + const selectables = createMemo(() => { + const r = root(); + + if (!r) { + return []; + } + + return Array.from((function* () { + const iterator = document.createTreeWalker(r, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: HTMLElement) => node.dataset.selectionKey ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP, + }); + + while (iterator.nextNode()) { + yield iterator.currentNode; + } + })()); + }); + + createRenderEffect(() => { + const children = c.toArray(); + const r = root(); + + if (!r) { + return; + } + + setTimeout(() => { + console.log(r, children, Array.from((function* () { + const iterator = document.createTreeWalker(r, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: HTMLElement) => node.dataset.selectionKey ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP, + }); + + while (iterator.nextNode()) { + console.log(iterator.currentNode); + + yield iterator.currentNode; + } + })())); + }, 10); + }); + + const createRange = (a?: HTMLElement, b?: HTMLElement): string[] => { + if (!a && !b) { + return []; + } + + if (!a) { + return [b!.dataset.selecatableKey!]; + } + + if (!b) { + return [a!.dataset.selecatableKey!]; + } + + if (a === b) { + return [a!.dataset.selecatableKey!]; + } + + const nodes = selectables(); + const aIndex = nodes.indexOf(a); + const bIndex = nodes.indexOf(b); + const selection = nodes.slice(Math.min(aIndex, bIndex), Math.max(aIndex, bIndex) + 1); + + console.log(aIndex, bIndex, nodes,); + + return selection.map(n => n.dataset.selectionKey); + }; + + const onPointerDown = (e: PointerEvent) => { + const key = e.target?.dataset.selectionKey; + + if (!key) { + return; + } + + const shift = Boolean(modifier() & Modifier.Shift); + const append = Boolean(modifier() & Modifier.Control); + + // Logic table + // shift | control | behavior | + // ------|---------|---------------------------------------------------| + // true | true | create range from latest to current and append | + // true | false | create range from latest to current and overwrite | + // false | true | append | + // false | false | overwrite / set | + + context.select(shift ? createRange(latest(), e.target as HTMLElement) : [key], { append }); + setLatest(e.target); + }; + + const onKeyboardEvent = (e: KeyboardEvent) => { + if (e.repeat || ['Control', 'Shift'].includes(e.key) === false) { + return; + } + + setModifier(state => { + if (e.shiftKey) { + state |= Modifier.Shift; + } + else { + state &= ~Modifier.Shift; + } + + if (e.ctrlKey) { + state |= Modifier.Control; + } + else { + state &= ~Modifier.Control; + } + + return state; + }); + }; + + onMount(() => { + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyboardEvent); + document.addEventListener('keyup', onKeyboardEvent); + }); + + onCleanup(() => { + if (isServer) { + return; + } + + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyboardEvent); + document.removeEventListener('keyup', onKeyboardEvent); + }); + + createEffect(() => { + console.log(selectables()); + }); + + return
{c()}
; +}; + +export const selectable = (element: HTMLElement, value: Accessor) => { + const context = useSelection(); + const key = createUniqueId(); + const isSelected = context.isSelected(key); + + context.add(key, value, element); + + createRenderEffect(() => { + element.dataset.selected = isSelected() ? 'true' : undefined; + }); + + element.classList.add(css.selectable); + element.dataset.selectionKey = key; +}; + +declare module "solid-js" { + namespace JSX { + interface Directives { + selectable: any; + } + } +} \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index e5e1144..186ef9b 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -1,6 +1,6 @@ import { Menu } from "~/features/menu"; import { Sidebar } from "~/components/sidebar"; -import { Component, createEffect, createResource, createSignal, For, onMount, Show } from "solid-js"; +import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, Show } from "solid-js"; import { Grid, load, useFiles } from "~/features/file"; import { createCommand, Modifier, noop } from "~/features/command"; import { GridContextType } from "~/features/file/grid"; @@ -108,6 +108,12 @@ export default function Edit(props) { }, { key: 'a', modifier: Modifier.Control }), } as const; + const mutated = createMemo(() => Object.values(ctx()?.rows ?? {}).filter(row => Object.values(row).some(lang => lang.original !== lang.value))); + + createEffect(() => { + console.log('KAAS', mutated()); + }); + return
@@ -131,7 +137,7 @@ export default function Edit(props) { { - file => {file().name} + (file, icon) => {icon} {file().name} } diff --git a/src/routes/(editor)/experimental.css b/src/routes/(editor)/experimental.css index 986e858..9f0b3d2 100644 --- a/src/routes/(editor)/experimental.css +++ b/src/routes/(editor)/experimental.css @@ -1,6 +1,6 @@ section.index { display: grid; - grid: 100% / auto 1fr; + grid: 100% / auto minmax(0, 1fr); inline-size: 100%; block-size: 100%; @@ -17,6 +17,11 @@ section.index { } & > section { + display: grid; + grid: 100% / 100%; + inline-size: 100%; + block-size: 100%; + padding-inline: 1em; } } \ No newline at end of file diff --git a/src/routes/(editor)/experimental.tsx b/src/routes/(editor)/experimental.tsx index bb89adb..a79539a 100644 --- a/src/routes/(editor)/experimental.tsx +++ b/src/routes/(editor)/experimental.tsx @@ -1,11 +1,12 @@ -import { createEffect, createMemo, createResource, createSignal, For, lazy, onMount, Suspense } from "solid-js"; +import { Component, createEffect, createMemo, createResource, createSignal, For, lazy, onMount, Suspense } from "solid-js"; import { useFiles } from "~/features/file"; import { Menu } from "~/features/menu"; -import "./experimental.css"; import { createCommand, Modifier } from "~/features/command"; import { emptyFolder, FolderEntry, Tree, walk } from "~/components/filetree"; import { createStore, produce } from "solid-js/store"; -import { Tab, Tabs, TabSimple, TabsSimple } from "~/components/tabs"; +import { Tab, Tabs } from "~/components/tabs"; +import "./experimental.css"; +import { selectable, SelectionProvider } from "~/features/selectable"; interface ExperimentalState { files: File[]; @@ -21,7 +22,7 @@ export default function Experimental() { }); const [showHiddenFiles, setShowHiddenFiles] = createSignal(false); const filters = createMemo(() => showHiddenFiles() ? [/^node_modules$/] : [/^node_modules$/, /^\..+$/]); - const [root, { mutate, refetch }] = createResource(() => files.get('root')); + const [root, { mutate, refetch }] = createResource(() => files?.get('root')); createEffect(() => { setState('numberOfFiles', state.files.length); @@ -84,12 +85,6 @@ export default function Experimental() { }, { key: 's', modifier: Modifier.Control | Modifier.Shift }), } as const; - const Content = lazy(async () => { - const text = Promise.resolve('this is text'); - - return { default: () => <>{text} }; - }); - return ( <> @@ -107,23 +102,31 @@ export default function Experimental() {
- + { - file => -
-                  
-                
-
+ file => + + }
-
+
); } + +const Content: Component<{ file: File }> = (props) => { + const [content] = createResource(async () => { + return await props.file.text(); + }); + + return +
{content()}
+
+}; \ No newline at end of file