got started on a dedicated editor component
This commit is contained in:
parent
44549c36be
commit
925ea142fb
7 changed files with 165 additions and 78 deletions
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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,29 +41,17 @@ 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()));
|
ref={setEditorRef}
|
||||||
};
|
class={`${css.textarea} ${props.class}`}
|
||||||
|
title={props.title ?? ''}
|
||||||
return <>
|
dir="auto"
|
||||||
<div class={css.search}>
|
lang={props.lang}
|
||||||
<input type="search" title={`${props.title ?? ''}-search`} placeholder="search for" oninput={e => source.query = e.target.value} />
|
innerHTML={text()}
|
||||||
<input type="search" title={`${props.title ?? ''}-replace`} placeholder="replace with" oninput={e => setReplacement(e.target.value)} />
|
data-placeholder={props.placeholder ?? ''}
|
||||||
<button onclick={() => replace()}>replace</button>
|
on:keydown={e => e.stopPropagation()}
|
||||||
</div>
|
on:pointerdown={e => e.stopPropagation()}
|
||||||
|
/>;
|
||||||
<div
|
|
||||||
ref={setEditorRef}
|
|
||||||
class={`${css.textarea} ${props.class}`}
|
|
||||||
title={props.title ?? ''}
|
|
||||||
dir="auto"
|
|
||||||
lang={props.lang}
|
|
||||||
innerHTML={text()}
|
|
||||||
data-placeholder={props.placeholder ?? ''}
|
|
||||||
on:keydown={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][]) => {
|
||||||
|
|
|
@ -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, ' ');
|
updateText(start, end, ' ');
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
updateText(start, end, '\n');
|
updateText(start, end, '</p><p> ');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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],
|
||||||
|
);
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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>;
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue