highlights api is awesome!!!
This commit is contained in:
parent
759169159d
commit
1af68dc85d
4 changed files with 177 additions and 93 deletions
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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],
|
||||||
|
);
|
|
@ -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) {
|
}
|
||||||
|
|
||||||
|
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
|
||||||
|
if (query.length < 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMarker(p)) {
|
let startIndex = 0;
|
||||||
return;
|
|
||||||
|
while (startIndex < text.length) {
|
||||||
|
const index = text.indexOf(query, startIndex);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors = grammarChecker(n.value, 'en-GB');
|
const end = index + query.length;
|
||||||
|
|
||||||
if (errors.length === 0) {
|
yield [index, end];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
|
startIndex = end;
|
||||||
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 {
|
|
||||||
return function (tree) {
|
|
||||||
visit(tree, isMarker, (n, i, p: Element) => {
|
|
||||||
if (typeof i !== 'number' || p === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.children.splice(i, 1, ...(n as Element).children);
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)]]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
.root {
|
.root {
|
||||||
|
position: relative;
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue