diff --git a/src/components/textarea/textarea.module.css b/src/components/textarea/textarea.module.css index 9491926..fc7a72f 100644 --- a/src/components/textarea/textarea.module.css +++ b/src/components/textarea/textarea.module.css @@ -17,4 +17,35 @@ & [data-marker="grammar"] { 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); + } + } } \ No newline at end of file diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 847297e..4a23a45 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -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 { createSelection } from '@solid-primitives/selection'; -import { defaultChecker as spellChecker } from './spellChecker'; -import { defaultChecker as grammarChecker } from './grammarChecker'; import { createSource } from '~/features/source'; import css from './textarea.module.css'; +import { isServer } from 'solid-js/web'; interface TextareaProps { class?: string; @@ -18,6 +17,7 @@ interface TextareaProps { export function Textarea(props: TextareaProps) { const [selection, setSelection] = createSelection(); + const [editorRef, setEditorRef] = createSignal(); const source = createSource(props.value); @@ -29,43 +29,114 @@ export function Textarea(props: TextareaProps) { source.in = props.value; }); - const onInput = debounce(() => { - const [el, start, end] = untrack(() => selection()); + const mutate = debounce(() => { + const [el, start, end] = selection(); + const ref = editorRef(); - if (el) { - source.out = el.innerHTML; + if (ref) { + source.out = ref.innerHTML; - el.style.height = `1px`; - el.style.height = `${2 + el.scrollHeight}px`; + ref.style.height = `1px`; + ref.style.height = `${2 + ref.scrollHeight}px`; - setSelection([el, start, end]); + setSelection([ref, start, end]); } }, 300); - const spellingErrors = createMemo(() => spellChecker(source.out, props.lang)); - const grammarErrors = createMemo(() => grammarChecker(source.out, props.lang)); + onMount(() => { + new MutationObserver(mutate).observe(editorRef()!, { + subtree: true, + childList: true, + characterData: true, + }); + }); - // const html = createMemo(() => { - // return source.out.split('').map((letter, index) => { - // const spellingOpen = spellingErrors().some(([start]) => start === index) ? `` : ''; - // const spellingClose = spellingErrors().some(([, end]) => end === index) ? `` : ''; + return <> + +
e.stopPropagation()} + on:pointerdown={e => e.stopPropagation()} + /> + ; +} - // const grammarOpen = grammarErrors().some(([start]) => start === index) ? `` : ''; - // const grammarClose = grammarErrors().some(([, end]) => end === index) ? `` : ''; +const Suggestions: Component = () => { + const [selection] = createSelection(); + const [suggestionRef, setSuggestionRef] = createSignal(); + const [suggestions, setSuggestions] = createSignal([]); - // return `${grammarOpen}${spellingOpen}${letter}${spellingClose}${grammarClose}`; - // }).join(''); - // }); + const marker = createMemo(() => { + if (isServer) { + return; + } - return
e.stopPropagation()} - on:pointerdown={e => e.stopPropagation()} - />; -} \ No newline at end of file + 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((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 + { + suggestion =>
  • {suggestion}
  • + }
    +
    ; +}; + +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; +}; \ No newline at end of file diff --git a/src/features/source/source.ts b/src/features/source/source.ts index 42a5913..e6e26af 100644 --- a/src/features/source/source.ts +++ b/src/features/source/source.ts @@ -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 { const wrapInMarker = (text: Text, type: string): Element => ({ type: 'element', @@ -67,6 +69,10 @@ function addErrors(): Transformer { return; } + if (isMarker(p)) { + return; + } + const errors = grammarChecker(n.value, 'en-GB'); if (errors.length === 0) { @@ -85,6 +91,10 @@ function addErrors(): Transformer { return; } + if (isMarker(p)) { + return; + } + const errors = spellChecker(n.value, 'en-GB'); if (errors.length === 0) { @@ -101,15 +111,13 @@ function addErrors(): Transformer { } function clearErrors(): Transformer { - const test = (n: Node) => n.type === 'element' && Object.hasOwn(n.properties, 'dataMarker'); - return function (tree) { - visit(tree, test, (n, i, p: Element) => { + visit(tree, isMarker, (n, i, p: Element) => { if (typeof i !== 'number' || p === undefined) { 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 []; let lastIndex = 0; + const threshold = .75//.99; - return Array.from(subject.matchAll(regex)).filter(() => Math.random() >= .99).flatMap(({ 0: match, index }) => { + return Array.from(subject.matchAll(regex)).filter(() => Math.random() >= threshold).flatMap(({ 0: match, index }) => { const end = index + match.length; const result = [ [false, subject.slice(lastIndex, index)], diff --git a/src/routes/(editor)/experimental/formatter.module.css b/src/routes/(editor)/experimental/formatter.module.css index 1d473ec..0f90a50 100644 --- a/src/routes/(editor)/experimental/formatter.module.css +++ b/src/routes/(editor)/experimental/formatter.module.css @@ -13,7 +13,7 @@ background-color: var(--surface-500); border-radius: var(--radii-xl); - & > * { + & > :is(textarea, .textarea) { overflow: auto; padding: .5em; background-color: transparent; diff --git a/src/routes/(editor)/experimental/formatter.tsx b/src/routes/(editor)/experimental/formatter.tsx index ed08987..ce40a6d 100644 --- a/src/routes/(editor)/experimental/formatter.tsx +++ b/src/routes/(editor)/experimental/formatter.tsx @@ -38,6 +38,6 @@ export default function Formatter(props: {}) { return
    -