highlights api is awesome!!!

This commit is contained in:
Chris Kruining 2025-02-18 23:20:56 +11:00
parent 759169159d
commit 1af68dc85d
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
4 changed files with 177 additions and 93 deletions

View file

@ -10,15 +10,25 @@
unicode-bidi: plaintext; unicode-bidi: plaintext;
cursor: text; cursor: text;
& [data-marker="spelling"] { & ::highlight(search-results) {
background-color: var(--secondary-900);
}
& ::highlight(spelling-error) {
text-decoration-line: spelling-error; text-decoration-line: spelling-error;
} }
& [data-marker="grammar"] { & ::highlight(grammar-error) {
text-decoration-line: grammar-error; text-decoration-line: grammar-error;
} }
} }
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
}
.suggestions { .suggestions {
position-anchor: --suggestions; position-anchor: --suggestions;

View file

@ -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 { debounce } from '@solid-primitives/scheduled';
import { createSelection } from '@solid-primitives/selection'; import { createSelection, getTextNodes } from '@solid-primitives/selection';
import { createSource } from '~/features/source'; import { createSource } from '~/features/source';
import css from './textarea.module.css';
import { isServer } from 'solid-js/web'; import { isServer } from 'solid-js/web';
import css from './textarea.module.css';
interface TextareaProps { interface TextareaProps {
class?: string; class?: string;
@ -30,7 +30,7 @@ export function Textarea(props: TextareaProps) {
}); });
const mutate = debounce(() => { const mutate = debounce(() => {
const [el, start, end] = selection(); const [, start, end] = selection();
const ref = editorRef(); const ref = editorRef();
if (ref) { 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 <> return <>
<Suggestions /> <Suggestions />
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
<div <div
ref={setEditorRef} ref={setEditorRef}
class={`${css.textarea} ${props.class}`} class={`${css.textarea} ${props.class}`}
@ -140,3 +153,51 @@ const findMarkerNode = (node: Node | null) => {
return node; return node;
}; };
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<RegExpExecArray>(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],
);

View file

@ -1,8 +1,9 @@
import { onMount } from "solid-js"; import { createEffect, onMount } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { unified, Transformer } from 'unified' import { unified } from 'unified'
import { Node, Text, Element } from 'hast' import { Text, Root } from 'hast'
import { visit } from "unist-util-visit"; import { visit } from "unist-util-visit";
import { decode } from "~/utilities";
import remarkParse from 'remark-parse' import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype' import remarkRehype from 'remark-rehype'
import remarkStringify from 'remark-stringify' import remarkStringify from 'remark-stringify'
@ -10,20 +11,54 @@ import rehypeParse from 'rehype-dom-parse'
import rehypeRemark from 'rehype-remark' import rehypeRemark from 'rehype-remark'
import rehypeStringify from 'rehype-dom-stringify' 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 { export interface Source {
in: string; in: string;
out: 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. // TODO :: make this configurable, right now we can only do markdown <--> html.
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(addErrors).use(rehypeStringify); const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
const outToInProcessor = unified().use(rehypeParse).use(clearErrors).use(rehypeRemark).use(remarkStringify, { bullet: '-' }); const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(initalValue: string): Source { export function createSource(initalValue: string): Source {
const [store, setStore] = createStore({ in: initalValue, out: '' }); const [store, setStore] = createStore<SourceStore>({ in: initalValue, out: '', plain: '', query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
onMount(() => { 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 { return {
@ -31,9 +66,12 @@ export function createSource(initalValue: string): Source {
return store.in; return store.in;
}, },
set in(next) { set in(next) {
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(next));
setStore({ setStore({
in: next, 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; return store.out;
}, },
set out(next) { set out(next) {
const ast = outToInProcessor.parse(next);
setStore({ setStore({
in: String(outToInProcessor.processSync(next)).trim(), in: String(outToInProcessor.stringify(outToInProcessor.runSync(ast))).trim(),
out: next, 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 { visit(tree, n => n.type === 'text', (n) => {
const wrapInMarker = (text: Text, type: string): Element => ({ nodes.push((n as Text).value);
type: 'element',
tagName: 'span',
properties: {
dataMarker: type,
},
children: [
text
]
}); });
return function (tree) { return decode(nodes.join(''));
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, 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;
}))
});
}
} }
function clearErrors(): Transformer { function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
return function (tree) { if (query.length < 1) {
visit(tree, isMarker, (n, i, p: Element) => {
if (typeof i !== 'number' || p === undefined) {
return; 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); const grammarChecker = checker(/\w+\s+\w+/gi);
function checker(regex: RegExp) { function checker(regex: RegExp) {
return (subject: string, lang: string): (readonly [boolean, string])[] => { return (subject: string, lang: string): [number, number][] => {
return []; return [];
let lastIndex = 0;
const threshold = .75//.99; const threshold = .75//.99;
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).flatMap<readonly [boolean, string]>(({ 0: match, index }) => { return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
const end = index + match.length; return [index, index + match.length] as const;
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)]]);
} }
} }

View file

@ -1,4 +1,5 @@
.root { .root {
position: relative;
margin: 1em; margin: 1em;
padding: .5em; padding: .5em;
gap: 1em; gap: 1em;