stash
This commit is contained in:
parent
f3069b12af
commit
487e41c2d7
10 changed files with 163 additions and 69 deletions
21
package.json
21
package.json
|
@ -1,5 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "calque",
|
"name": "calque",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"bun": ">=1"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solid-primitives/clipboard": "^1.5.10",
|
"@solid-primitives/clipboard": "^1.5.10",
|
||||||
"@solid-primitives/destructure": "^0.2.0",
|
"@solid-primitives/destructure": "^0.2.0",
|
||||||
|
@ -19,16 +24,6 @@
|
||||||
"ts-pattern": "^5.6.0",
|
"ts-pattern": "^5.6.0",
|
||||||
"vinxi": "^0.4.3"
|
"vinxi": "^0.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vinxi dev",
|
|
||||||
"build": "vinxi build",
|
|
||||||
"start": "vinxi start",
|
|
||||||
"version": "vinxi version"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "^15.11.7",
|
"@happy-dom/global-registrator": "^15.11.7",
|
||||||
"@sinonjs/fake-timers": "^14.0.0",
|
"@sinonjs/fake-timers": "^14.0.0",
|
||||||
|
@ -41,5 +36,11 @@
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vite-plugin-solid-svg": "^0.8.1",
|
"vite-plugin-solid-svg": "^0.8.1",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vinxi dev",
|
||||||
|
"build": "vinxi build",
|
||||||
|
"start": "vinxi start",
|
||||||
|
"version": "vinxi version"
|
||||||
}
|
}
|
||||||
}
|
}
|
8
src/components/textarea/grammarChecker.ts
Normal file
8
src/components/textarea/grammarChecker.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
|
||||||
|
const regex = /\w+\s+\w+/gi;
|
||||||
|
export function defaultChecker(subject: string, lang: string): [number, number][] {
|
||||||
|
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= .5).map(({ 0: match, index }) => {
|
||||||
|
return [index, index + match.length - 1];
|
||||||
|
});
|
||||||
|
}
|
3
src/components/textarea/index.ts
Normal file
3
src/components/textarea/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
|
export { Textarea } from './textarea';
|
8
src/components/textarea/spellChecker.ts
Normal file
8
src/components/textarea/spellChecker.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
|
||||||
|
const regex = /\w+/gi;
|
||||||
|
export function defaultChecker(subject: string, lang: string): [number, number][] {
|
||||||
|
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= .5).map(({ 0: match, index }) => {
|
||||||
|
return [index, index + match.length - 1];
|
||||||
|
});
|
||||||
|
}
|
24
src/components/textarea/textarea.module.css
Normal file
24
src/components/textarea/textarea.module.css
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
.textarea {
|
||||||
|
/* Make sure resizing works as intended */
|
||||||
|
display: block;
|
||||||
|
overflow: clip auto;
|
||||||
|
resize: block;
|
||||||
|
|
||||||
|
white-space: wrap;
|
||||||
|
min-block-size: max(2em, 100%);
|
||||||
|
max-block-size: 50em;
|
||||||
|
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
white-space-collapse: preserve;
|
||||||
|
text-wrap-mode: wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spellingError {
|
||||||
|
text-decoration-line: spelling-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grammarError {
|
||||||
|
text-decoration-line: grammar-error;
|
||||||
|
}
|
80
src/components/textarea/textarea.tsx
Normal file
80
src/components/textarea/textarea.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||||
|
import { decode } from '~/utilities';
|
||||||
|
import { debounce } from '@solid-primitives/scheduled';
|
||||||
|
import { defaultChecker as spellChecker } from './spellChecker';
|
||||||
|
import { defaultChecker as grammarChecker } from './grammarChecker';
|
||||||
|
import css from './textarea.module.css';
|
||||||
|
|
||||||
|
interface TextareaProps {
|
||||||
|
class?: string;
|
||||||
|
value: string;
|
||||||
|
lang: string;
|
||||||
|
oninput?: (event: InputEvent) => any;
|
||||||
|
spellChecker?: any;
|
||||||
|
grammarChecker?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea(props: TextareaProps) {
|
||||||
|
const [value, setValue] = createSignal<string>(decode(props.value));
|
||||||
|
const [element, setElement] = createSignal<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setValue(decode(props.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const el = element();
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.height = `1px`;
|
||||||
|
el.style.height = `${2 + element()!.scrollHeight}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutate = debounce(() => {
|
||||||
|
props.oninput?.(new InputEvent('input', {
|
||||||
|
data: value(),
|
||||||
|
}))
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setValue(element()!.innerText.trim());
|
||||||
|
|
||||||
|
resize();
|
||||||
|
mutate();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spellingErrors = createMemo(() => spellChecker(value(), props.lang));
|
||||||
|
const grammarErrors = createMemo(() => grammarChecker(value(), props.lang));
|
||||||
|
|
||||||
|
const html = createMemo(() => {
|
||||||
|
return value().split('').map((letter, index) => {
|
||||||
|
const spellingOpen = spellingErrors().some(([start]) => start === index) ? `<span class="${css.spellingError}">` : '';
|
||||||
|
const spellingClose = spellingErrors().some(([, end]) => end === index) ? `</span>` : '';
|
||||||
|
|
||||||
|
const grammarOpen = grammarErrors().some(([start]) => start === index) ? `<span class="${css.grammarError}">` : '';
|
||||||
|
const grammarClose = grammarErrors().some(([, end]) => end === index) ? `</span>` : '';
|
||||||
|
|
||||||
|
return `${grammarOpen}${spellingOpen}${letter}${spellingClose}${grammarClose}`;
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div
|
||||||
|
ref={setElement}
|
||||||
|
class={`${css.textarea} ${props.class}`}
|
||||||
|
lang={props.lang}
|
||||||
|
dir="auto"
|
||||||
|
onkeyup={onKeyUp}
|
||||||
|
on:keydown={e => e.stopPropagation()}
|
||||||
|
on:pointerdown={e => e.stopPropagation()}
|
||||||
|
contentEditable
|
||||||
|
innerHTML={html()}
|
||||||
|
/>;
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import { useI18n } from "../i18n";
|
||||||
import { CommandType } from "./command";
|
import { CommandType } from "./command";
|
||||||
import { useCommands } from "./context";
|
import { useCommands } from "./context";
|
||||||
import css from "./palette.module.css";
|
import css from "./palette.module.css";
|
||||||
|
import { split_by_filter } from "~/utilities";
|
||||||
|
|
||||||
export interface CommandPaletteApi {
|
export interface CommandPaletteApi {
|
||||||
readonly open: Accessor<boolean>;
|
readonly open: Accessor<boolean>;
|
||||||
|
@ -59,11 +60,9 @@ export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any,
|
||||||
(item, ctx) => {
|
(item, ctx) => {
|
||||||
const label = t(item.label) as string;
|
const label = t(item.label) as string;
|
||||||
const filter = ctx.filter().toLowerCase();
|
const filter = ctx.filter().toLowerCase();
|
||||||
const length = filter.length;
|
|
||||||
const indices = [0, ...Array.from(label.matchAll(new RegExp(filter, 'gi')).flatMap(({ index }) => [index, index + length]))];
|
|
||||||
|
|
||||||
return <For each={indices.map((current, i) => label.slice(current, indices[i + 1]))}>{
|
return <For each={split_by_filter(label, filter)}>{
|
||||||
(part) => <Show when={part.toLowerCase() === filter} fallback={part}><b>{part}</b></Show>
|
([is_hit, part]) => <Show when={is_hit} fallback={part}><b>{part}</b></Show>
|
||||||
}</For>;
|
}</For>;
|
||||||
}
|
}
|
||||||
}</SearchableList>
|
}</SearchableList>
|
||||||
|
|
|
@ -1,19 +1,6 @@
|
||||||
.textarea {
|
.textarea {
|
||||||
resize: vertical;
|
|
||||||
min-block-size: max(2em, 100%);
|
|
||||||
max-block-size: 50em;
|
|
||||||
|
|
||||||
background-color: var(--surface-600);
|
background-color: var(--surface-600);
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
border-color: var(--text-2);
|
border: 1px solid var(--text-2);
|
||||||
border-radius: var(--radii-s);
|
border-radius: var(--radii-s);
|
||||||
|
|
||||||
&:has(::spelling-error, ::grammar-error) {
|
|
||||||
border-color: var(--fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
& ::spelling-error {
|
|
||||||
outline: 1px solid var(--fail);
|
|
||||||
text-decoration: yellow underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { Accessor, Component, createEffect, createMemo, createSignal, JSX, untrack } from "solid-js";
|
import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show, untrack } from "solid-js";
|
||||||
import { decode, Mutation } from "~/utilities";
|
import { decode, Mutation } from "~/utilities";
|
||||||
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
||||||
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
|
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
|
||||||
|
@ -6,6 +6,7 @@ import { SelectionItem } from "../selectable";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
import css from "./grid.module.css"
|
import css from "./grid.module.css"
|
||||||
|
import { Textarea } from "~/components/textarea";
|
||||||
|
|
||||||
export type Entry = { key: string } & { [lang: string]: string };
|
export type Entry = { key: string } & { [lang: string]: string };
|
||||||
export interface GridApi {
|
export interface GridApi {
|
||||||
|
@ -43,7 +44,7 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
|
||||||
label: t('feature.file.grid.key'),
|
label: t('feature.file.grid.key'),
|
||||||
renderer: ({ value }) => props.children?.(value) ?? value.split('.').at(-1),
|
renderer: ({ value }) => props.children?.(value) ?? value.split('.').at(-1),
|
||||||
},
|
},
|
||||||
...locales().map<Column<Entry>>(lang => ({
|
...locales().toSorted().map<Column<Entry>>(lang => ({
|
||||||
id: lang,
|
id: lang,
|
||||||
label: lang,
|
label: lang,
|
||||||
renderer: ({ row, column, value, mutate }) => {
|
renderer: ({ row, column, value, mutate }) => {
|
||||||
|
@ -59,8 +60,8 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
|
||||||
// Normalize dataset in order to make sure all the files have the correct structure
|
// Normalize dataset in order to make sure all the files have the correct structure
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// For tracking
|
// For tracking
|
||||||
props.rows
|
props.rows;
|
||||||
const value = untrack(() => rows.value);
|
// const value = untrack(() => rows.value);
|
||||||
|
|
||||||
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 ?? ''])) }))
|
||||||
});
|
});
|
||||||
|
@ -95,44 +96,10 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextArea: Component<{ row: number, key: string, lang: string, value: string, oninput?: (event: InputEvent) => any }> = (props) => {
|
const TextArea: Component<{ row: number, key: string, lang: string, value: string, oninput?: (event: InputEvent) => any }> = (props) => {
|
||||||
const [element, setElement] = createSignal<HTMLTextAreaElement>();
|
return <Textarea
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const el = element();
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.style.height = `1px`;
|
|
||||||
el.style.height = `${2 + element()!.scrollHeight}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutate = debounce(() => {
|
|
||||||
props.oninput?.(new InputEvent('input', {
|
|
||||||
data: element()?.value.trim(),
|
|
||||||
}))
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
|
||||||
resize();
|
|
||||||
mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = createMemo(() => decode(props.value));
|
|
||||||
|
|
||||||
return <textarea
|
|
||||||
ref={setElement}
|
|
||||||
class={css.textarea}
|
class={css.textarea}
|
||||||
value={value()}
|
value={props.value}
|
||||||
lang={props.lang}
|
lang={props.lang}
|
||||||
placeholder={`${props.key} in ${props.lang}`}
|
oninput={props.oninput}
|
||||||
name={`${props.row}[${props.lang}]`}
|
|
||||||
dir="auto"
|
|
||||||
spellcheck={true}
|
|
||||||
wrap="soft"
|
|
||||||
onkeyup={onKeyUp}
|
|
||||||
on:keydown={e => e.stopPropagation()}
|
|
||||||
on:pointerdown={e => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
};
|
};
|
|
@ -9,6 +9,23 @@ export const splitAt = (subject: string, index: number): readonly [string, strin
|
||||||
|
|
||||||
return [subject.slice(0, index), subject.slice(index + 1)];
|
return [subject.slice(0, index), subject.slice(index + 1)];
|
||||||
};
|
};
|
||||||
|
export function* gen__split_by_filter(subject: string, filter: string): Generator<readonly [boolean, string], void, unknown> {
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const { 0: match, index, ...rest } of subject.matchAll(new RegExp(filter, 'gmi'))) {
|
||||||
|
const end = index + match.length;
|
||||||
|
|
||||||
|
yield [false, subject.slice(lastIndex, index)];
|
||||||
|
yield [true, subject.slice(index, end)];
|
||||||
|
|
||||||
|
lastIndex = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield [false, subject.slice(lastIndex, subject.length)];
|
||||||
|
}
|
||||||
|
export function split_by_filter(subject: string, filter: string): (readonly [boolean, string])[] {
|
||||||
|
return Array.from<readonly [boolean, string]>(gen__split_by_filter(subject, filter));
|
||||||
|
}
|
||||||
|
|
||||||
const decodeRegex = /(?<!\\)\\(t|b|n|r|f|'|"|u[0-9a-f]{1,4})/gi;
|
const decodeRegex = /(?<!\\)\\(t|b|n|r|f|'|"|u[0-9a-f]{1,4})/gi;
|
||||||
const decodeReplacer = (_: any, char: string) => ({
|
const decodeReplacer = (_: any, char: string) => ({
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue