got started on a dedicated editor component

This commit is contained in:
Chris Kruining 2025-03-03 09:39:35 +01:00
parent 44549c36be
commit 925ea142fb
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
7 changed files with 165 additions and 78 deletions

View file

@ -23,16 +23,6 @@
} }
} }
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
display: block grid;
grid-auto-flow: row;
gap: .5em;
}
.suggestions { .suggestions {
position-anchor: --suggestions; position-anchor: --suggestions;

View file

@ -1,10 +1,8 @@
import { Component, createEffect, createMemo, createSignal, For, on, untrack } from 'solid-js'; import { createEffect, createSignal, on } from 'solid-js';
import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { getTextNodes } from '@solid-primitives/selection';
import { isServer } from 'solid-js/web';
import { createEditContext } from '~/features/editor'; import { createEditContext } from '~/features/editor';
import { createSource } from '~/features/source'; import { createSource } from '~/features/source';
import css from './textarea.module.css'; import css from './textarea.module.css';
import { debounce } from '@solid-primitives/scheduled';
interface TextareaProps { interface TextareaProps {
class?: string; class?: string;
@ -18,11 +16,10 @@ interface TextareaProps {
} }
export function Textarea(props: TextareaProps) { export function Textarea(props: TextareaProps) {
const [replacement, setReplacement] = createSignal('');
const [editorRef, setEditorRef] = createSignal<HTMLElement>(); const [editorRef, setEditorRef] = createSignal<HTMLElement>();
const source = createSource(() => props.value); const source = createSource(() => props.value);
const [text, { mutate }] = createEditContext(editorRef, () => source.out); const [text] = createEditContext(editorRef, () => source.out);
createEffect(() => { createEffect(() => {
source.out = text(); source.out = text();
@ -44,18 +41,7 @@ export function Textarea(props: TextareaProps) {
createHighlights(ref, 'search-results', errors); createHighlights(ref, 'search-results', errors);
})); }));
const replace = () => { return <div
mutate(text => text.replaceAll(source.query, replacement()));
};
return <>
<div class={css.search}>
<input type="search" title={`${props.title ?? ''}-search`} placeholder="search for" oninput={e => source.query = e.target.value} />
<input type="search" title={`${props.title ?? ''}-replace`} placeholder="replace with" oninput={e => setReplacement(e.target.value)} />
<button onclick={() => replace()}>replace</button>
</div>
<div
ref={setEditorRef} ref={setEditorRef}
class={`${css.textarea} ${props.class}`} class={`${css.textarea} ${props.class}`}
title={props.title ?? ''} title={props.title ?? ''}
@ -65,8 +51,7 @@ export function Textarea(props: TextareaProps) {
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 createHighlights = (node: Node, type: string, ranges: [number, number][]) => { const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {

View file

@ -12,7 +12,7 @@ type Editor = [Accessor<string>, { select(range: Range): void, mutate(setter: (t
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor { export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
if (isServer) { if (isServer) {
return [value, { return [value, {
select(range) { }, select() { },
mutate() { }, mutate() { },
}]; }];
} }
@ -101,8 +101,6 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
selection.removeAllRanges(); selection.removeAllRanges();
} }
console.log('is it me?');
selection.addRange(range); selection.addRange(range);
}); });
} }
@ -149,7 +147,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;'); updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;');
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
updateText(start, end, '\n'); updateText(start, end, '</p><p>&nbsp;');
} }
}, },
}); });
@ -270,7 +268,7 @@ declare global {
interface EditContextConstructor { interface EditContextConstructor {
new(): EditContext; new(): EditContext;
new(options: Partial<{ text: string, selectionStart: number, selectionEnd: number }>): EditContext; new(options: Partial<Pick<EditContext, 'text' | 'selectionStart' | 'selectionEnd'>>): EditContext;
readonly prototype: EditContext; readonly prototype: EditContext;
} }

View file

@ -1,20 +1,101 @@
import { createContextProvider } from "@solid-primitives/context"; import { createContextProvider } from "@solid-primitives/context";
import { createEffect, ParentProps } from "solid-js"; import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js";
import { createEditor } from "./context"; import { createEditor } from "./context";
import { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection";
interface EditorContextType {
text: Accessor<string>;
readonly source: Source;
select(range: Range): void;
mutate(setter: (prev: string) => string): void;
}
const [EditorProvider, useEditor] = createContextProvider((props: { ref: Element, value: string }) => { interface EditorContextProps extends Record<string, unknown> {
const [text] = createEditor(() => props.ref, () => props.value); ref: Accessor<Element | undefined>;
value: string;
oninput?: (value: string) => void;
}
const [EditorProvider, useEditor] = createContextProvider<EditorContextType, EditorContextProps>((props) => {
const source = createSource(() => props.value);
const [text, { select, mutate }] = createEditor(props.ref, () => source.out);
createEffect(() => { createEffect(() => {
console.log(text()); props.oninput?.(source.in);
}); });
return { text }; createEffect(() => {
}); source.out = text();
});
createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'spelling-error', errors);
}));
createEffect(on(() => [props.ref()!, source.grammarErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'grammar-error', errors);
}));
createEffect(on(() => [props.ref()!, source.queryResults] as const, ([ref, results]) => {
createHighlights(ref, 'search-results', results);
}));
return {
text,
select,
mutate,
source,
};
}, { text: () => '', source: {} as Source, select() { }, mutate() { } });
export { useEditor }; export { useEditor };
export function Editor(props: ParentProps<{ ref: Element, value: string }>) { export function Editor(props: ParentProps<{ value: string, oninput?: (value: string) => void }>) {
return <EditorProvider ref={props.ref} value={props.value}>{props.children}</EditorProvider>; const [ref, setRef] = createSignal<Element>();
return <EditorProvider ref={ref} value={props.value} oninput={props.oninput}>
{props.children}
<Content ref={setRef} />
</EditorProvider>;
} }
function Content(props: { ref: Setter<Element | undefined> }) {
const { text } = useEditor();
return <div ref={props.ref} innerHTML={text()} />;
}
const createHighlights = (node: Node, type: string, indices: [number, number][]) => {
queueMicrotask(() => {
const nodes = getTextNodes(node);
CSS.highlights.set(type, new Highlight(...indices.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

@ -121,26 +121,14 @@ function plainTextStringify() {
}; };
} }
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> { function findMatches(text: string, query: string): [number, number][] {
if (query.length < 1) { if (query.length < 1) {
return; return [];
} }
let startIndex = 0; return text.matchAll(new RegExp(query, 'gi')).map<[number, number]>(({ index }) => {
return [index, index + query.length];
while (startIndex < text.length) { }).toArray();
const index = text.indexOf(query, startIndex);
if (index === -1) {
break;
}
const end = index + query.length;
yield [index, end];
startIndex = end;
}
} }
const spellChecker = checker(/\w+/gi); const spellChecker = checker(/\w+/gi);

View file

@ -19,4 +19,32 @@
padding: .5em; padding: .5em;
background-color: transparent; background-color: transparent;
} }
& ::highlight(search-results) {
background-color: var(--secondary-900);
}
& ::highlight(spelling-error) {
text-decoration-line: spelling-error;
}
& ::highlight(grammar-error) {
text-decoration-line: grammar-error;
}
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
display: block grid;
grid-auto-flow: row;
padding: .5em;
gap: .5em;
background-color: var(--surface-700);
border-radius: var(--radii-m);
box-shadow: var(--shadow-2);
}
} }

View file

@ -1,8 +1,8 @@
import { createSignal } from "solid-js"; import { createEffect, createSignal } from "solid-js";
import { debounce } from "@solid-primitives/scheduled"; import { debounce } from "@solid-primitives/scheduled";
import { Textarea } from "~/components/textarea"; import { Textarea } from "~/components/textarea";
import css from './formatter.module.css'; import css from './formatter.module.css';
import { Editor } from "~/features/editor"; import { Editor, useEditor } from "~/features/editor";
const tempVal = ` const tempVal = `
# Header # Header
@ -13,15 +13,13 @@ this is *a string* that contains italicized text
> Dorothy followed her through many of the beautiful rooms in her castle. > Dorothy followed her through many of the beautiful rooms in her castle.
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
> #### The quarterly results look great! > #### The quarterly results look great!
> >
> - Revenue was off the chart. > - Revenue was off the chart.
> - Profits were higher than ever. > - Profits were higher than ever.
> >
> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
>
> *Everything* is going according to **plan**. > *Everything* is going according to **plan**.
- First item - First item
@ -39,6 +37,25 @@ export default function Formatter(props: {}) {
return <div class={css.root}> return <div class={css.root}>
<textarea oninput={onInput} title="markdown">{value()}</textarea> <textarea oninput={onInput} title="markdown">{value()}</textarea>
<Textarea class={css.textarea} title="html" value={value()} oninput={setValue} lang="en-GB" /> <Editor value={value()} oninput={setValue}>
<SearchAndReplace />
</Editor>
{/* <Textarea class={css.textarea} title="html" value={value()} oninput={setValue} lang="en-GB" /> */}
</div>; </div>;
} }
function SearchAndReplace() {
const { mutate, source } = useEditor();
const [replacement, setReplacement] = createSignal('');
const replace = () => {
mutate(text => text.replaceAll(source.query, replacement()));
};
return <form class={css.search}>
<input type="search" title="editor-search" placeholder="search for" oninput={e => source.query = e.target.value} />
<input type="search" title="editor-replace" placeholder="replace with" oninput={e => setReplacement(e.target.value)} />
<button onclick={() => replace()}>replace</button>
</form>;
};