Merge branch 'main' into experiment/editContext
This commit is contained in:
commit
603719de38
8 changed files with 350 additions and 32 deletions
1
.github/workflows/app.yml
vendored
1
.github/workflows/app.yml
vendored
|
@ -49,6 +49,7 @@ jobs:
|
|||
|
||||
- name: Build container images
|
||||
run: |
|
||||
echo 'SESSION_SECRET=${{ secrets.SESSION_PASSWORD }}' > .env
|
||||
docker build . --file Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/$IMAGE_NAME:${{needs.versionize.outputs.semver}}
|
||||
docker build . --file Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/$IMAGE_NAME:latest
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
|
|||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env
|
||||
# RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV SERVER_PRESET=bun
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, createSignal, on, onMount } 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';
|
||||
import css from './textarea.module.css';
|
||||
|
@ -18,8 +19,25 @@ interface TextareaProps {
|
|||
export function Textarea(props: TextareaProps) {
|
||||
const [selection, setSelection] = createSelection();
|
||||
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
|
||||
let mounted = false;
|
||||
|
||||
const source = createSource(() => props.value);
|
||||
const source = createSource(props.value);
|
||||
|
||||
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
oninput?.(text);
|
||||
}));
|
||||
|
||||
onMount((() => {
|
||||
mounted = true;
|
||||
}));
|
||||
|
||||
createEffect(() => {
|
||||
source.in = props.value;
|
||||
});
|
||||
|
||||
const mutate = debounce(() => {
|
||||
const [, start, end] = selection();
|
||||
|
@ -59,18 +77,116 @@ export function Textarea(props: TextareaProps) {
|
|||
createHighlights(ref, 'search-results', errors);
|
||||
}));
|
||||
|
||||
return <div
|
||||
contentEditable
|
||||
return <>
|
||||
<Suggestions />
|
||||
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
|
||||
<div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
title={props.title ?? ''}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
lang={props.lang}
|
||||
innerHTML={source.out}
|
||||
data-placeholder={props.placeholder ?? ''}
|
||||
on:keydown={e => e.stopPropagation()}
|
||||
on:pointerdown={e => e.stopPropagation()}
|
||||
/>;
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
const Suggestions: Component = () => {
|
||||
const [selection] = createSelection();
|
||||
const [suggestionRef, setSuggestionRef] = createSignal<HTMLElement>();
|
||||
const [suggestions, setSuggestions] = createSignal<string[]>([]);
|
||||
|
||||
const marker = createMemo(() => {
|
||||
if (isServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [n] = selection();
|
||||
const s = window.getSelection();
|
||||
|
||||
if (n === null || s === null || s.rangeCount < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined;
|
||||
});
|
||||
|
||||
createEffect<HTMLElement | undefined>((prev) => {
|
||||
if (prev) {
|
||||
prev.style.setProperty('anchor-name', null);
|
||||
}
|
||||
|
||||
const m = marker();
|
||||
const ref = untrack(() => suggestionRef()!);
|
||||
|
||||
if (m === undefined) {
|
||||
if (ref.matches(':popover-open')) {
|
||||
ref.hidePopover();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
m.style.setProperty('anchor-name', '--suggestions');
|
||||
|
||||
if (ref.matches(':not(:popover-open)')) {
|
||||
ref.showPopover();
|
||||
}
|
||||
|
||||
ref.focus()
|
||||
|
||||
return m;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
marker();
|
||||
|
||||
setSuggestions(Array(Math.ceil(Math.random() * 5)).fill('').map((_, i) => `suggestion ${i}`));
|
||||
});
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
marker()?.replaceWith(document.createTextNode(e.target.textContent));
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
return <menu ref={setSuggestionRef} class={css.suggestions} popover="manual" onkeydown={onKeyDown}>
|
||||
<For each={suggestions()}>{
|
||||
suggestion => <li onpointerdown={onPointerDown}>{suggestion}</li>
|
||||
}</For>
|
||||
</menu>;
|
||||
};
|
||||
|
||||
const findMarkerNode = (node: Node | null) => {
|
||||
while (node !== null) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).hasAttribute('data-marker')) {
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const spellChecker = checker(/\w+/gi);
|
||||
const grammarChecker = checker(/\w+\s+\w+/gi);
|
||||
|
||||
function checker(regex: RegExp) {
|
||||
return (subject: string, lang: string): [number, number][] => {
|
||||
// return [];
|
||||
|
||||
const threshold = .75//.99;
|
||||
|
||||
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
|
||||
return [index, index + match.length] as const;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
|
||||
|
|
|
@ -28,7 +28,7 @@ const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
|
|||
: ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, _key: n._key.slice(key.length + 1) }))) })
|
||||
);
|
||||
|
||||
return group(rows.map<R>(r => ({ ...r, _key: r.value.key }))) as any;
|
||||
return group(rows.filter(r => r.value.key).map<R>(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 }) {
|
||||
|
@ -64,19 +64,18 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
|
|||
const [api, setApi] = createSignal<GridCompApi<Entry>>();
|
||||
|
||||
// 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?.({
|
||||
|
|
|
@ -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<string, { handle: FileSystemFileHandle, file: File }> { }
|
||||
interface Contents extends Map<string, Map<string, string>> { }
|
||||
|
@ -13,8 +18,16 @@ export const read = (file: File): Promise<Map<string, string> | undefined> => {
|
|||
}
|
||||
};
|
||||
|
||||
export const readFiles = (directory: Accessor<FileSystemDirectoryHandle>): Accessor<Files> => {
|
||||
return createPolled<FileSystemDirectoryHandle, Files>(directory, async (directory, prev) => {
|
||||
export const readFiles = (directory: Accessor<FileSystemDirectoryHandle | undefined>): Accessor<Files> => {
|
||||
return (!isServer && 'FileSystemObserver' in window) ? readFiles__observer(directory) : readFiles__polled(directory)
|
||||
};
|
||||
|
||||
const readFiles__polled = (directory: Accessor<FileSystemDirectoryHandle | undefined>): Accessor<Files> => {
|
||||
return createPolled<FileSystemDirectoryHandle | undefined, Files>(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<FileSystemDirectoryHandle>): Acces
|
|||
}, { interval: 1000, initialValue: {} });
|
||||
};
|
||||
|
||||
const readFiles__observer = (directory: Accessor<FileSystemDirectoryHandle | undefined>): Accessor<Files> => {
|
||||
const [files, setFiles] = createSignal<Files>({});
|
||||
|
||||
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<FileSystemDirectoryHandle | undefined>((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<FileSystemDirectoryHandle>): Accessor<Contents> => {
|
||||
return createPolled<FileSystemDirectoryHandle, Contents>(directory, async (directory, prev) => {
|
||||
export const contentsOf = (directory: Accessor<FileSystemDirectoryHandle | undefined>): Accessor<Contents> => {
|
||||
return (!isServer && 'FileSystemObserver' in window) ? contentsOf__observer(directory) : contentsOf__polled(directory)
|
||||
};
|
||||
|
||||
const contentsOf__observer = (directory: Accessor<FileSystemDirectoryHandle | undefined>): Accessor<Contents> => {
|
||||
const [contents, setContents] = createSignal<Contents>(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<FileSystemDirectoryHandle | undefined>((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<FileSystemDirectoryHandle | undefined>): Accessor<Contents> => {
|
||||
return createPolled<FileSystemDirectoryHandle | undefined, Contents>(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<K, V> {
|
||||
[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<string>;
|
||||
readonly type: FileSystemChangeType;
|
||||
readonly relativePathMovedFrom?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface FileSystemObserver {
|
||||
observe(
|
||||
handle: FileSystemHandle,
|
||||
options?: FileSystemObserverObserveOptions
|
||||
): Promise<void>;
|
||||
unobserve(handle: FileSystemHandle): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
interface FileSystemObserverConstructor {
|
||||
new(callback: FileSystemObserverCallback): FileSystemObserver;
|
||||
readonly prototype: FileSystemObserver;
|
||||
}
|
||||
|
||||
var FileSystemObserver: FileSystemObserverConstructor;
|
||||
}
|
|
@ -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<Gr
|
|||
const [api, setApi] = createSignal<GridApi>();
|
||||
|
||||
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(() => {
|
||||
|
|
|
@ -19,6 +19,7 @@ export default function Experimental(props: ParentProps) {
|
|||
<Menu.Item command={goTo.withLabel('context-menu').with('context-menu')} />
|
||||
<Menu.Item command={goTo.withLabel('textarea').with('textarea')} />
|
||||
<Menu.Item command={goTo.withLabel('editor').with('editor')} />
|
||||
<Menu.Item command={goTo.withLabel('file-system-observer').with('file-system-observer')} />
|
||||
</Menu.Root>
|
||||
|
||||
<ErrorBoundary fallback={e => <ErrorComp error={e} />}>
|
||||
|
|
32
src/routes/(editor)/experimental/file-system-observer.tsx
Normal file
32
src/routes/(editor)/experimental/file-system-observer.tsx
Normal file
|
@ -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<FileSystemDirectoryHandle>();
|
||||
|
||||
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 <div>
|
||||
<button onclick={open}>Select folder</button>
|
||||
</div>;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue