diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 80433d2..c04480d 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,4 +1,4 @@ -import { Component, createEffect, createMemo, createSignal, For, onMount, untrack } from 'solid-js'; +import { Component, createEffect, createMemo, createSignal, For, on, onMount, untrack } from 'solid-js'; import { debounce } from '@solid-primitives/scheduled'; import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { createSource } from '~/features/source'; @@ -18,12 +18,21 @@ interface TextareaProps { export function Textarea(props: TextareaProps) { const [selection, setSelection] = createSelection(); const [editorRef, setEditorRef] = createSignal(); + let mounted = false; const source = createSource(props.value); - createEffect(() => { - props.oninput?.(source.in); - }); + createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => { + if (!mounted) { + return; + } + + oninput?.(text); + })); + + onMount((() => { + mounted = true; + })); createEffect(() => { source.in = props.value; @@ -109,13 +118,19 @@ const Suggestions: Component = () => { const ref = untrack(() => suggestionRef()!); if (m === undefined) { - ref.hidePopover(); + if (ref.matches(':popover-open')) { + ref.hidePopover(); + } return; } m.style.setProperty('anchor-name', '--suggestions'); - ref.showPopover(); + + if (ref.matches(':not(:popover-open)')) { + ref.showPopover(); + } + ref.focus() return m; diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 59696f7..0deb756 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -29,7 +29,7 @@ const groupBy = (rows: DataSetRowNode[]) => { : ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, _key: n._key.slice(key.length + 1) }))) }) ); - return group(rows.map(r => ({ ...r, _key: r.value.key }))) as any; + return group(rows.filter(r => r.value.key).map(r => ({ ...r, _key: r.value.key }))) as any; } export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any, children?: (key: string) => JSX.Element }) { @@ -65,19 +65,18 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[], const [api, setApi] = createSignal>(); // Normalize dataset in order to make sure all the files have the correct structure - createEffect(() => { - // For tracking - props.rows; - // const value = untrack(() => rows.value); + // createEffect(() => { + // // For tracking + // props.rows; - rows.mutateEach(({ key, ...locales }) => ({ key, ...Object.fromEntries(Object.entries(locales).map(([locale, value]) => [locale, value ?? ''])) })) - }); + // rows.mutateEach(({ key, ...locales }) => ({ key, ...Object.fromEntries(Object.entries(locales).map(([locale, value]) => [locale, value ?? ''])) })) + // }); - createEffect(() => { - const l = addedLocales(); + // createEffect(() => { + // const l = addedLocales(); - rows.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); - }); + // rows.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); + // }); createEffect(() => { props.api?.({ diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts index 544bbd2..a4b68bd 100644 --- a/src/features/file/helpers.ts +++ b/src/features/file/helpers.ts @@ -1,6 +1,11 @@ import { Accessor, createEffect, from, createSignal } from "solid-js"; import { json } from "./parser"; import { filter } from "~/utilities"; +import { isServer } from "solid-js/web"; +import { installIntoGlobal } from 'iterator-helpers-polyfill'; +import { debounce } from "@solid-primitives/scheduled"; + +installIntoGlobal(); interface Files extends Record { } interface Contents extends Map> { } @@ -13,8 +18,16 @@ export const read = (file: File): Promise | undefined> => { } }; -export const readFiles = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const readFiles = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? readFiles__observer(directory) : readFiles__polled(directory) +}; + +const readFiles__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const next: Files = Object.fromEntries(await Array.fromAsync( filter(directory.values(), (handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')), async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] @@ -39,9 +52,130 @@ export const readFiles = (directory: Accessor): Acces }, { interval: 1000, initialValue: {} }); }; +const readFiles__observer = (directory: Accessor): Accessor => { + const [files, setFiles] = createSignal({}); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + + setFiles(prev => ({ ...prev, [id]: { file, handle } })); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setFiles(Object.fromEntries( + await dir.values() + .filter((handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')) + .map(async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] as const) + .toArray() + )); + })(); + + return dir; + }); + + return files; +}; + +const HANDLE = Symbol('handle'); const LAST_MODIFIED = Symbol('lastModified'); -export const contentsOf = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const contentsOf = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? contentsOf__observer(directory) : contentsOf__polled(directory) +}; + +const contentsOf__observer = (directory: Accessor): Accessor => { + const [contents, setContents] = createSignal(new Map); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + setContents(prev => new Map([...prev, [id, entries]])); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setContents(new Map(await walk(dir).map(async ({ id, file }) => { + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + return [id, entries] as const; + }).toArray())); + })(); + + return dir; + }); + + return contents; +}; + +const contentsOf__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const files = await Array.fromAsync(walk(directory)); const next = async () => new Map(await Promise.all(files.map(async ({ id, file }) => { @@ -116,6 +250,41 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): declare global { interface Map { + [HANDLE]: FileSystemFileHandle; [LAST_MODIFIED]: number; } + + type FileSystemObserverCallback = ( + records: FileSystemChangeRecord[], + observer: FileSystemObserver + ) => void; + + interface FileSystemObserverObserveOptions { + recursive?: boolean; + } + + type FileSystemChangeType = 'appeared' | 'disappeared' | 'modified' | 'moved' | 'unknown' | 'errored'; + + interface FileSystemChangeRecord { + readonly changedHandle: FileSystemHandle; + readonly relativePathComponents: ReadonlyArray; + readonly type: FileSystemChangeType; + readonly relativePathMovedFrom?: ReadonlyArray; + } + + interface FileSystemObserver { + observe( + handle: FileSystemHandle, + options?: FileSystemObserverObserveOptions + ): Promise; + unobserve(handle: FileSystemHandle): void; + disconnect(): void; + } + + interface FileSystemObserverConstructor { + new(callback: FileSystemObserverCallback): FileSystemObserver; + readonly prototype: FileSystemObserver; + } + + var FileSystemObserver: FileSystemObserverConstructor; } \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 95604ae..0eed71e 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -13,9 +13,8 @@ import { useI18n } from "~/features/i18n"; import { makePersisted } from "@solid-primitives/storage"; import { writeClipboard } from "@solid-primitives/clipboard"; import { destructure } from "@solid-primitives/destructure"; -import css from "./edit.module.css"; import { contentsOf } from "~/features/file/helpers"; -import { createHtmlParser, createMarkdownParser, createSource } from "~/features/source"; +import css from "./edit.module.css"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; @@ -346,6 +345,7 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter(); const files = readFiles(() => props.directory); + // const __contents = contentsOf(() => props.directory); const [contents] = createResource(files, (files) => Promise.all(Object.entries(files).map(async ([id, { file, handle }]) => ({ id, handle, lang: file.name.split('.').at(0)!, entries: (await read(file))! }))), { initialValue: [] }); const [entries, rows] = destructure(() => { diff --git a/src/routes/(editor)/experimental.tsx b/src/routes/(editor)/experimental.tsx index 5ddac59..6282f50 100644 --- a/src/routes/(editor)/experimental.tsx +++ b/src/routes/(editor)/experimental.tsx @@ -18,6 +18,7 @@ export default function Experimental(props: ParentProps) { + }> diff --git a/src/routes/(editor)/experimental/file-system-observer.tsx b/src/routes/(editor)/experimental/file-system-observer.tsx new file mode 100644 index 0000000..44f4eb9 --- /dev/null +++ b/src/routes/(editor)/experimental/file-system-observer.tsx @@ -0,0 +1,32 @@ +import { createEffect, createSignal, on } from "solid-js"; +import { readFiles } from "~/features/file"; +import { contentsOf } from "~/features/file/helpers"; + +export default function FileObserver(props: {}) { + const [dir, setDir] = createSignal(); + + const files = readFiles(dir); + const contents = contentsOf(dir); + + const open = async () => { + const handle = await window.showDirectoryPicker(); + + setDir(handle) + }; + + createEffect(() => { + console.log('dir', dir()); + }); + + createEffect(() => { + console.log('files', files()); + }); + + createEffect(() => { + console.log('contents', contents()); + }); + + return
+ +
; +} \ No newline at end of file