implement inital suggestion application
This commit is contained in:
parent
544e974493
commit
759169159d
5 changed files with 151 additions and 40 deletions
|
@ -18,3 +18,34 @@
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
||||||
// 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
|
|
||||||
class={`${css.textarea} ${props.class}`}
|
class={`${css.textarea} ${props.class}`}
|
||||||
contentEditable
|
contentEditable
|
||||||
dir="auto"
|
dir="auto"
|
||||||
lang={props.lang}
|
lang={props.lang}
|
||||||
oninput={onInput}
|
|
||||||
innerHTML={source.out}
|
innerHTML={source.out}
|
||||||
data-placeholder={props.placeholder ?? ''}
|
data-placeholder={props.placeholder ?? ''}
|
||||||
on:keydown={e => e.stopPropagation()}
|
on:keydown={e => e.stopPropagation()}
|
||||||
on:pointerdown={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) {
|
||||||
|
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;
|
||||||
|
};
|
|
@ -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)],
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue