implement inital suggestion application

This commit is contained in:
Chris Kruining 2025-02-18 14:15:17 +11:00
parent 544e974493
commit 759169159d
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
5 changed files with 151 additions and 40 deletions

View file

@ -17,4 +17,35 @@
& [data-marker="grammar"] { & [data-marker="grammar"] {
text-decoration-line: grammar-error; text-decoration-line: grammar-error;
} }
}
.suggestions {
position-anchor: --suggestions;
position: fixed;
inset-inline-start: anchor(start);
inset-block-start: anchor(end);
position-try-fallbacks: flip-block, flip-inline;
margin: 0;
padding: var(--padding-m) 0;
border: 1px solid var(--surface-300);
background-color: var(--surface-600);
box-shadow: var(--shadow-2);
list-style: none;
display: none;
grid-auto-flow: row;
&:popover-open {
display: block grid;
}
& > li {
padding: var(--padding-m);
&:hover {
background-color: oklch(from var(--info) l c h / .5);
}
}
} }

View file

@ -1,10 +1,9 @@
import { createEffect, createMemo, untrack } from 'solid-js'; import { Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js';
import { debounce } from '@solid-primitives/scheduled'; import { debounce } from '@solid-primitives/scheduled';
import { createSelection } from '@solid-primitives/selection'; import { createSelection } from '@solid-primitives/selection';
import { defaultChecker as spellChecker } from './spellChecker';
import { defaultChecker as grammarChecker } from './grammarChecker';
import { createSource } from '~/features/source'; import { createSource } from '~/features/source';
import css from './textarea.module.css'; import css from './textarea.module.css';
import { isServer } from 'solid-js/web';
interface TextareaProps { interface TextareaProps {
class?: string; class?: string;
@ -18,6 +17,7 @@ interface TextareaProps {
export function Textarea(props: TextareaProps) { export function Textarea(props: TextareaProps) {
const [selection, setSelection] = createSelection(); const [selection, setSelection] = createSelection();
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
const source = createSource(props.value); const source = createSource(props.value);
@ -29,43 +29,114 @@ export function Textarea(props: TextareaProps) {
source.in = props.value; source.in = props.value;
}); });
const onInput = debounce(() => { const mutate = debounce(() => {
const [el, start, end] = untrack(() => selection()); const [el, start, end] = selection();
const ref = editorRef();
if (el) { if (ref) {
source.out = el.innerHTML; source.out = ref.innerHTML;
el.style.height = `1px`; ref.style.height = `1px`;
el.style.height = `${2 + el.scrollHeight}px`; ref.style.height = `${2 + ref.scrollHeight}px`;
setSelection([el, start, end]); setSelection([ref, start, end]);
} }
}, 300); }, 300);
const spellingErrors = createMemo(() => spellChecker(source.out, props.lang)); onMount(() => {
const grammarErrors = createMemo(() => grammarChecker(source.out, props.lang)); new MutationObserver(mutate).observe(editorRef()!, {
subtree: true,
childList: true,
characterData: true,
});
});
// const html = createMemo(() => { return <>
// return source.out.split('').map((letter, index) => { <Suggestions />
// const spellingOpen = spellingErrors().some(([start]) => start === index) ? `<span class="${css.spellingError}">` : ''; <div
// const spellingClose = spellingErrors().some(([, end]) => end === index) ? `</span>` : ''; ref={setEditorRef}
class={`${css.textarea} ${props.class}`}
contentEditable
dir="auto"
lang={props.lang}
innerHTML={source.out}
data-placeholder={props.placeholder ?? ''}
on:keydown={e => e.stopPropagation()}
on:pointerdown={e => e.stopPropagation()}
/>
</>;
}
// const grammarOpen = grammarErrors().some(([start]) => start === index) ? `<span class="${css.grammarError}">` : ''; const Suggestions: Component = () => {
// const grammarClose = grammarErrors().some(([, end]) => end === index) ? `</span>` : ''; const [selection] = createSelection();
const [suggestionRef, setSuggestionRef] = createSignal<HTMLElement>();
const [suggestions, setSuggestions] = createSignal<string[]>([]);
// return `${grammarOpen}${spellingOpen}${letter}${spellingClose}${grammarClose}`; const marker = createMemo(() => {
// }).join(''); if (isServer) {
// }); return;
}
return <div const [n] = selection();
class={`${css.textarea} ${props.class}`} const s = window.getSelection();
contentEditable
dir="auto" if (n === null || s === null || s.rangeCount < 1) {
lang={props.lang} return;
oninput={onInput} }
innerHTML={source.out}
data-placeholder={props.placeholder ?? ''} return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined;
on:keydown={e => e.stopPropagation()} });
on:pointerdown={e => e.stopPropagation()}
/>; createEffect<HTMLElement | undefined>((prev) => {
} if (prev) {
prev.style.setProperty('anchor-name', null);
}
const m = marker();
const ref = untrack(() => suggestionRef()!);
if (m === undefined) {
ref.hidePopover();
return;
}
m.style.setProperty('anchor-name', '--suggestions');
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;
};

View file

@ -49,6 +49,8 @@ export function createSource(initalValue: string): Source {
}; };
} }
const isMarker = (node: Node) => node.type === 'element' && Object.hasOwn((node as Element).properties, 'dataMarker')
function addErrors(): Transformer { function addErrors(): Transformer {
const wrapInMarker = (text: Text, type: string): Element => ({ const wrapInMarker = (text: Text, type: string): Element => ({
type: 'element', type: 'element',
@ -67,6 +69,10 @@ function addErrors(): Transformer {
return; return;
} }
if (isMarker(p)) {
return;
}
const errors = grammarChecker(n.value, 'en-GB'); const errors = grammarChecker(n.value, 'en-GB');
if (errors.length === 0) { if (errors.length === 0) {
@ -85,6 +91,10 @@ function addErrors(): Transformer {
return; return;
} }
if (isMarker(p)) {
return;
}
const errors = spellChecker(n.value, 'en-GB'); const errors = spellChecker(n.value, 'en-GB');
if (errors.length === 0) { if (errors.length === 0) {
@ -101,15 +111,13 @@ function addErrors(): Transformer {
} }
function clearErrors(): Transformer { function clearErrors(): Transformer {
const test = (n: Node) => n.type === 'element' && Object.hasOwn(n.properties, 'dataMarker');
return function (tree) { return function (tree) {
visit(tree, test, (n, i, p: Element) => { visit(tree, isMarker, (n, i, p: Element) => {
if (typeof i !== 'number' || p === undefined) { if (typeof i !== 'number' || p === undefined) {
return; return;
} }
p.children.splice(i, 1, ...n.children); p.children.splice(i, 1, ...(n as Element).children);
}) })
} }
} }
@ -122,8 +130,9 @@ function checker(regex: RegExp) {
return []; return [];
let lastIndex = 0; let lastIndex = 0;
const threshold = .75//.99;
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= .99).flatMap<readonly [boolean, string]>(({ 0: match, index }) => { return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).flatMap<readonly [boolean, string]>(({ 0: match, index }) => {
const end = index + match.length; const end = index + match.length;
const result = [ const result = [
[false, subject.slice(lastIndex, index)], [false, subject.slice(lastIndex, index)],

View file

@ -13,7 +13,7 @@
background-color: var(--surface-500); background-color: var(--surface-500);
border-radius: var(--radii-xl); border-radius: var(--radii-xl);
& > * { & > :is(textarea, .textarea) {
overflow: auto; overflow: auto;
padding: .5em; padding: .5em;
background-color: transparent; background-color: transparent;

View file

@ -38,6 +38,6 @@ export default function Formatter(props: {}) {
return <div class={css.root}> return <div class={css.root}>
<textarea oninput={onInput}>{value()}</textarea> <textarea oninput={onInput}>{value()}</textarea>
<Textarea value={value()} oninput={setValue} lang="en-GB" /> <Textarea class={css.textarea} value={value()} oninput={setValue} lang="en-GB" />
</div>; </div>;
} }