diff --git a/src/components/textarea/textarea.module.css b/src/components/textarea/textarea.module.css
index fc7a72f..70e634e 100644
--- a/src/components/textarea/textarea.module.css
+++ b/src/components/textarea/textarea.module.css
@@ -10,15 +10,25 @@
unicode-bidi: plaintext;
cursor: text;
- & [data-marker="spelling"] {
+ & ::highlight(search-results) {
+ background-color: var(--secondary-900);
+ }
+
+ & ::highlight(spelling-error) {
text-decoration-line: spelling-error;
}
- & [data-marker="grammar"] {
+ & ::highlight(grammar-error) {
text-decoration-line: grammar-error;
}
}
+.search {
+ position: absolute;
+ inset-inline-end: 0;
+ inset-block-start: 0;
+}
+
.suggestions {
position-anchor: --suggestions;
diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx
index 4a23a45..80433d2 100644
--- a/src/components/textarea/textarea.tsx
+++ b/src/components/textarea/textarea.tsx
@@ -1,9 +1,9 @@
-import { Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js';
+import { Component, createEffect, createMemo, createSignal, For, onMount, untrack } from 'solid-js';
import { debounce } from '@solid-primitives/scheduled';
-import { createSelection } from '@solid-primitives/selection';
+import { createSelection, getTextNodes } from '@solid-primitives/selection';
import { createSource } from '~/features/source';
-import css from './textarea.module.css';
import { isServer } from 'solid-js/web';
+import css from './textarea.module.css';
interface TextareaProps {
class?: string;
@@ -30,7 +30,7 @@ export function Textarea(props: TextareaProps) {
});
const mutate = debounce(() => {
- const [el, start, end] = selection();
+ const [, start, end] = selection();
const ref = editorRef();
if (ref) {
@@ -51,8 +51,21 @@ export function Textarea(props: TextareaProps) {
});
});
+ createEffect(() => {
+ createHighlights(editorRef()!, 'spelling-error', source.spellingErrors);
+ });
+
+ createEffect(() => {
+ createHighlights(editorRef()!, 'grammar-error', source.grammarErrors);
+ });
+
+ createEffect(() => {
+ createHighlights(editorRef()!, 'search-results', source.queryResults);
+ });
+
return <>
+ source.query = e.target.value} />
{
}
return node;
-};
\ No newline at end of file
+};
+
+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(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][]) => {
+ queueMicrotask(() => {
+ const nodes = getTextNodes(node);
+
+ CSS.highlights.set(type, new Highlight(...ranges.map(([start, end]) => indicesToRange(start, end, nodes))));
+ });
+};
+
+const indicesToRange = (start: number, end: number, textNodes: Node[]) => {
+ const [startNode, startPos] = getRangeArgs(start, textNodes);
+ const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, textNodes);
+
+ const range = new Range();
+
+ if (startNode && endNode && startPos !== -1 && endPos !== -1) {
+ range.setStart(startNode, startPos);
+ range.setEnd(endNode, endPos);
+ }
+
+ return range;
+}
+
+const getRangeArgs = (offset: number, texts: Node[]): [node: Node | null, offset: number] =>
+ texts.reduce(
+ ([node, pos], text) =>
+ node
+ ? [node, pos]
+ : pos <= (text as Text).data.length
+ ? [text, pos]
+ : [null, pos - (text as Text).data.length],
+ [null, offset] as [node: Node | null, pos: number],
+ );
\ No newline at end of file
diff --git a/src/features/source/source.ts b/src/features/source/source.ts
index e6e26af..f5d2fcc 100644
--- a/src/features/source/source.ts
+++ b/src/features/source/source.ts
@@ -1,8 +1,9 @@
-import { onMount } from "solid-js";
+import { createEffect, onMount } from "solid-js";
import { createStore } from "solid-js/store";
-import { unified, Transformer } from 'unified'
-import { Node, Text, Element } from 'hast'
+import { unified } from 'unified'
+import { Text, Root } from 'hast'
import { visit } from "unist-util-visit";
+import { decode } from "~/utilities";
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkStringify from 'remark-stringify'
@@ -10,20 +11,54 @@ import rehypeParse from 'rehype-dom-parse'
import rehypeRemark from 'rehype-remark'
import rehypeStringify from 'rehype-dom-stringify'
+interface SourceStore {
+ in: string;
+ out: string;
+ plain: string;
+ query: string;
+ metadata: {
+ spellingErrors: [number, number][];
+ grammarErrors: [number, number][];
+ queryResults: [number, number][];
+ };
+}
+
export interface Source {
in: string;
out: string;
+ query: string;
+ readonly spellingErrors: [number, number][];
+ readonly grammarErrors: [number, number][];
+ readonly queryResults: [number, number][];
}
// TODO :: make this configurable, right now we can only do markdown <--> html.
-const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(addErrors).use(rehypeStringify);
-const outToInProcessor = unified().use(rehypeParse).use(clearErrors).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
+const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
+const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(initalValue: string): Source {
- const [store, setStore] = createStore({ in: initalValue, out: '' });
+ const [store, setStore] = createStore({ in: initalValue, out: '', plain: '', query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
onMount(() => {
- setStore('out', String(inToOutProcessor.processSync(initalValue)));
+ const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
+
+ setStore({
+ out: String(inToOutProcessor.stringify(ast)),
+ plain: String(unified().use(plainTextStringify).stringify(ast)),
+ });
+ });
+
+ createEffect(() => {
+ const value = store.plain;
+
+ setStore('metadata', {
+ spellingErrors: spellChecker(value, ''),
+ grammarErrors: grammarChecker(value, ''),
+ });
+ });
+
+ createEffect(() => {
+ setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
});
return {
@@ -31,9 +66,12 @@ export function createSource(initalValue: string): Source {
return store.in;
},
set in(next) {
+ const ast = inToOutProcessor.runSync(inToOutProcessor.parse(next));
+
setStore({
in: next,
- out: String(inToOutProcessor.processSync(next)),
+ out: String(inToOutProcessor.stringify(ast)),
+ plain: String(unified().use(plainTextStringify).stringify(ast)),
});
},
@@ -41,84 +79,67 @@ export function createSource(initalValue: string): Source {
return store.out;
},
set out(next) {
+ const ast = outToInProcessor.parse(next);
+
setStore({
- in: String(outToInProcessor.processSync(next)).trim(),
+ in: String(outToInProcessor.stringify(outToInProcessor.runSync(ast))).trim(),
out: next,
+ plain: String(unified().use(plainTextStringify).stringify(ast)),
});
},
+
+ get query() {
+ return store.query;
+ },
+ set query(next) {
+ setStore('query', next)
+ },
+
+ get spellingErrors() {
+ return store.metadata.spellingErrors;
+ },
+
+ get grammarErrors() {
+ return store.metadata.grammarErrors;
+ },
+
+ get queryResults() {
+ return store.metadata.queryResults;
+ },
};
}
-const isMarker = (node: Node) => node.type === 'element' && Object.hasOwn((node as Element).properties, 'dataMarker')
+function plainTextStringify() {
+ this.compiler = function (tree: Root) {
+ const nodes: string[] = [];
-function addErrors(): Transformer {
- const wrapInMarker = (text: Text, type: string): Element => ({
- type: 'element',
- tagName: 'span',
- properties: {
- dataMarker: type,
- },
- children: [
- text
- ]
- });
-
- return function (tree) {
- visit(tree, n => n.type === 'text', (n, i, p: Element) => {
- if (typeof i !== 'number' || p === undefined) {
- return;
- }
-
- if (isMarker(p)) {
- return;
- }
-
- const errors = grammarChecker(n.value, 'en-GB');
-
- if (errors.length === 0) {
- return;
- }
-
- p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
- const textNode: Text = { type: 'text', value };
-
- return isHit ? wrapInMarker(textNode, 'grammar') : textNode;
- }))
+ visit(tree, n => n.type === 'text', (n) => {
+ nodes.push((n as Text).value);
});
- visit(tree, n => n.type === 'text', (n, i, p: Element) => {
- if (typeof i !== 'number' || p === undefined) {
- return;
- }
-
- if (isMarker(p)) {
- return;
- }
-
- const errors = spellChecker(n.value, 'en-GB');
-
- if (errors.length === 0) {
- return;
- }
-
- p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
- const textNode: Text = { type: 'text', value };
-
- return isHit ? wrapInMarker(textNode, 'spelling') : textNode;
- }))
- });
- }
+ return decode(nodes.join(''));
+ };
}
-function clearErrors(): Transformer {
- return function (tree) {
- visit(tree, isMarker, (n, i, p: Element) => {
- if (typeof i !== 'number' || p === undefined) {
- return;
- }
+function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
+ if (query.length < 1) {
+ return;
+ }
- p.children.splice(i, 1, ...(n as Element).children);
- })
+ let startIndex = 0;
+
+ while (startIndex < text.length) {
+ const index = text.indexOf(query, startIndex);
+
+ if (index === -1) {
+ break;
+ }
+
+ const end = index + query.length;
+
+ yield [index, end];
+
+ startIndex = end;
}
}
@@ -126,22 +147,13 @@ const spellChecker = checker(/\w+/gi);
const grammarChecker = checker(/\w+\s+\w+/gi);
function checker(regex: RegExp) {
- return (subject: string, lang: string): (readonly [boolean, string])[] => {
+ return (subject: string, lang: string): [number, number][] => {
return [];
- let lastIndex = 0;
const threshold = .75//.99;
- 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)],
- [true, subject.slice(index, end)],
- ] as const;
-
- lastIndex = end;
-
- return result;
- }).concat([[false, subject.slice(lastIndex, subject.length)]]);
+ return Array.from(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
+ return [index, index + match.length] as const;
+ });
}
}
\ No newline at end of file
diff --git a/src/routes/(editor)/experimental/formatter.module.css b/src/routes/(editor)/experimental/formatter.module.css
index 0f90a50..c654237 100644
--- a/src/routes/(editor)/experimental/formatter.module.css
+++ b/src/routes/(editor)/experimental/formatter.module.css
@@ -1,4 +1,5 @@
.root {
+ position: relative;
margin: 1em;
padding: .5em;
gap: 1em;