This commit is contained in:
Chris Kruining 2025-03-03 15:58:53 +01:00
parent fa6bf5bbac
commit 11aab1dc1a
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
5 changed files with 106 additions and 38 deletions

View file

@ -7,13 +7,23 @@ import { createMap } from './map';
import { splice } from "~/utilities";
import rehypeParse from "rehype-parse";
type Editor = [Accessor<string>, { select(range: Range): void, mutate(setter: (text: string) => string): void }];
type Editor = [Accessor<string>, { select(range: Range): void, mutate(setter: (text: string) => string): void, readonly selection: Accessor<Range | undefined> }];
interface EditorStoreType {
text: string;
isComposing: boolean;
selection: Range | undefined;
characterBounds: DOMRect[];
controlBounds: DOMRect;
selectionBounds: DOMRect;
}
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
if (isServer) {
return [value, {
select() { },
mutate() { },
selection: () => undefined,
}];
}
@ -25,9 +35,10 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
text: value(),
});
const [store, setStore] = createStore({
const [store, setStore] = createStore<EditorStoreType>({
text: value(),
isComposing: false,
selection: undefined,
// Bounds
characterBounds: new Array<DOMRect>(),
@ -84,6 +95,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
context.updateSelection(...indexMap.toHtmlIndices(range));
context.updateSelectionBounds(range.getBoundingClientRect());
setStore('selection', range);
queueMicrotask(() => {
const selection = window.getSelection();
@ -198,6 +211,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
mutate(setter) {
setStore('text', setter);
},
selection: createMemo(() => store.selection),
}];
}

View file

@ -1,11 +1,13 @@
import { createContextProvider } from "@solid-primitives/context";
import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js";
import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js";
import { createEditor } from "./context";
import { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection";
import { isServer } from "solid-js/web";
interface EditorContextType {
text: Accessor<string>;
readonly text: Accessor<string>;
readonly selection: Accessor<Range | undefined>;
readonly source: Source;
select(range: Range): void;
mutate(setter: (prev: string) => string): void;
@ -19,7 +21,7 @@ interface EditorContextProps extends Record<string, unknown> {
const [EditorProvider, useEditor] = createContextProvider<EditorContextType, EditorContextProps>((props) => {
const source = createSource(() => props.value);
const [text, { select, mutate }] = createEditor(props.ref, () => source.out);
const [text, { select, mutate, selection }] = createEditor(props.ref, () => source.out);
createEffect(() => {
props.oninput?.(source.in);
@ -46,8 +48,15 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
select,
mutate,
source,
selection,
};
}, { text: () => '', source: {} as Source, select() { }, mutate() { } });
}, {
text: () => '',
selection: () => undefined,
source: {} as Source,
select() { },
mutate() { },
});
export { useEditor };

View file

@ -1,4 +1,4 @@
import { Accessor, createEffect } from "solid-js";
import { Accessor, createEffect, createMemo } from "solid-js";
import { createStore } from "solid-js/store";
import { unified } from 'unified'
import { visit } from "unist-util-visit";
@ -17,7 +17,7 @@ interface SourceStore {
in: string;
out: string;
plain: string;
query: string;
query: RegExp;
metadata: {
spellingErrors: [number, number][];
grammarErrors: [number, number][];
@ -28,7 +28,7 @@ interface SourceStore {
export interface Source {
in: string;
out: string;
query: string;
query: RegExp;
readonly spellingErrors: [number, number][];
readonly grammarErrors: [number, number][];
readonly queryResults: [number, number][];
@ -39,7 +39,7 @@ const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehype
const outToInProcessor = unified().use(isServer ? rehypeParse : rehypeDomParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(value: Accessor<string>): Source {
const [store, setStore] = createStore<SourceStore>({ in: '', out: '', plain: '', query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
const [store, setStore] = createStore<SourceStore>({ in: '', out: '', plain: '', query: new RegExp('', 'gi'), metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
const src: Source = {
get in() {
@ -121,13 +121,9 @@ function plainTextStringify() {
};
}
function findMatches(text: string, query: string): [number, number][] {
if (query.length < 1) {
return [];
}
return text.matchAll(new RegExp(query, 'gi')).map<[number, number]>(({ index }) => {
return [index, index + query.length];
function findMatches(text: string, query: RegExp): [number, number][] {
return text.matchAll(query).map<[number, number]>(({ 0: match, index }) => {
return [index, index + match.length];
}).toArray();
}

View file

@ -32,19 +32,37 @@
text-decoration-line: grammar-error;
}
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
.editor {
display: block grid;
grid-auto-flow: row;
grid: auto 1fr / 100%;
padding: .5em;
gap: .5em;
.toolbar {
display: block grid;
grid-auto-flow: column;
place-content: start;
}
background-color: var(--surface-700);
border-radius: var(--radii-m);
box-shadow: var(--shadow-2);
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
grid-template-columns: 1fr 1fr;
padding: .5em;
gap: .5em;
background-color: var(--surface-700);
border-radius: var(--radii-m);
box-shadow: var(--shadow-2);
&:popover-open {
display: block grid;
}
& > label {
display: contents;
}
}
}
}

View file

@ -1,4 +1,4 @@
import { createSignal } from "solid-js";
import { createEffect, createMemo, createSignal, onMount } from "solid-js";
import { debounce } from "@solid-primitives/scheduled";
import { Editor, useEditor } from "~/features/editor";
import css from './editor.module.css';
@ -37,23 +37,53 @@ export default function Formatter(props: {}) {
return <div class={css.root}>
<textarea oninput={onInput} title="markdown">{value()}</textarea>
<Editor value={value()} oninput={setValue}>
<SearchAndReplace />
</Editor>
<div class={css.editor}>
<Editor value={value()} oninput={setValue}>
<Toolbar />
<SearchAndReplace />
</Editor>
</div>
</div>;
}
function Toolbar() {
const { mutate, selection } = useEditor();
const bold = () => {
console.log('toggle text bold', selection());
};
return <div class={css.toolbar}>
<button onclick={bold}>bold</button>
</div>
}
function SearchAndReplace() {
const { mutate, source } = useEditor();
const [replacement, setReplacement] = createSignal('');
const [term, setTerm] = createSignal('');
const [caseInsensitive, setCaseInsensitive] = createSignal(true);
const replace = () => {
mutate(text => text.replaceAll(source.query, replacement()));
const query = createMemo(() => new RegExp(term(), caseInsensitive() ? 'gi' : 'g'));
createEffect(() => {
source.query = query();
});
const replace = (e: SubmitEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
form.reset();
mutate(text => text.replaceAll(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>
return <form on:submit={replace} class={css.search} popover="manual">
<label><span>Case insensitive</span><input type="checkbox" checked={caseInsensitive()} oninput={e => setCaseInsensitive(e.target.checked)} /></label>
<label><span>Search for</span><input type="search" title="editor-search" oninput={e => setTerm(e.target.value)} /></label>
<label><span>Replace with</span><input type="search" title="editor-replace" oninput={e => setReplacement(e.target.value)} /></label>
<button>replace</button>
</form>;
};