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

View file

@ -1,11 +1,13 @@
import { createContextProvider } from "@solid-primitives/context"; 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 { createEditor } from "./context";
import { createSource, Source } from "../source"; import { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection"; import { getTextNodes } from "@solid-primitives/selection";
import { isServer } from "solid-js/web";
interface EditorContextType { interface EditorContextType {
text: Accessor<string>; readonly text: Accessor<string>;
readonly selection: Accessor<Range | undefined>;
readonly source: Source; readonly source: Source;
select(range: Range): void; select(range: Range): void;
mutate(setter: (prev: string) => string): 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 [EditorProvider, useEditor] = createContextProvider<EditorContextType, EditorContextProps>((props) => {
const source = createSource(() => props.value); 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(() => { createEffect(() => {
props.oninput?.(source.in); props.oninput?.(source.in);
@ -46,8 +48,15 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
select, select,
mutate, mutate,
source, source,
selection,
}; };
}, { text: () => '', source: {} as Source, select() { }, mutate() { } }); }, {
text: () => '',
selection: () => undefined,
source: {} as Source,
select() { },
mutate() { },
});
export { useEditor }; 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 { createStore } from "solid-js/store";
import { unified } from 'unified' import { unified } from 'unified'
import { visit } from "unist-util-visit"; import { visit } from "unist-util-visit";
@ -17,7 +17,7 @@ interface SourceStore {
in: string; in: string;
out: string; out: string;
plain: string; plain: string;
query: string; query: RegExp;
metadata: { metadata: {
spellingErrors: [number, number][]; spellingErrors: [number, number][];
grammarErrors: [number, number][]; grammarErrors: [number, number][];
@ -28,7 +28,7 @@ interface SourceStore {
export interface Source { export interface Source {
in: string; in: string;
out: string; out: string;
query: string; query: RegExp;
readonly spellingErrors: [number, number][]; readonly spellingErrors: [number, number][];
readonly grammarErrors: [number, number][]; readonly grammarErrors: [number, number][];
readonly queryResults: [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: '-' }); const outToInProcessor = unified().use(isServer ? rehypeParse : rehypeDomParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(value: Accessor<string>): Source { 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 = { const src: Source = {
get in() { get in() {
@ -121,13 +121,9 @@ function plainTextStringify() {
}; };
} }
function findMatches(text: string, query: string): [number, number][] { function findMatches(text: string, query: RegExp): [number, number][] {
if (query.length < 1) { return text.matchAll(query).map<[number, number]>(({ 0: match, index }) => {
return []; return [index, index + match.length];
}
return text.matchAll(new RegExp(query, 'gi')).map<[number, number]>(({ index }) => {
return [index, index + query.length];
}).toArray(); }).toArray();
} }

View file

@ -32,19 +32,37 @@
text-decoration-line: grammar-error; text-decoration-line: grammar-error;
} }
.search { .editor {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
display: block grid; display: block grid;
grid-auto-flow: row; grid: auto 1fr / 100%;
padding: .5em; .toolbar {
gap: .5em; display: block grid;
grid-auto-flow: column;
place-content: start;
}
background-color: var(--surface-700); .search {
border-radius: var(--radii-m); position: absolute;
box-shadow: var(--shadow-2); 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 { debounce } from "@solid-primitives/scheduled";
import { Editor, useEditor } from "~/features/editor"; import { Editor, useEditor } from "~/features/editor";
import css from './editor.module.css'; import css from './editor.module.css';
@ -37,23 +37,53 @@ 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>
<Editor value={value()} oninput={setValue}> <div class={css.editor}>
<SearchAndReplace /> <Editor value={value()} oninput={setValue}>
</Editor> <Toolbar />
<SearchAndReplace />
</Editor>
</div>
</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() { function SearchAndReplace() {
const { mutate, source } = useEditor(); const { mutate, source } = useEditor();
const [replacement, setReplacement] = createSignal(''); const [replacement, setReplacement] = createSignal('');
const [term, setTerm] = createSignal('');
const [caseInsensitive, setCaseInsensitive] = createSignal(true);
const replace = () => { const query = createMemo(() => new RegExp(term(), caseInsensitive() ? 'gi' : 'g'));
mutate(text => text.replaceAll(source.query, replacement()));
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}> return <form on:submit={replace} class={css.search} popover="manual">
<input type="search" title="editor-search" placeholder="search for" oninput={e => source.query = e.target.value} /> <label><span>Case insensitive</span><input type="checkbox" checked={caseInsensitive()} oninput={e => setCaseInsensitive(e.target.checked)} /></label>
<input type="search" title="editor-replace" placeholder="replace with" oninput={e => setReplacement(e.target.value)} /> <label><span>Search for</span><input type="search" title="editor-search" oninput={e => setTerm(e.target.value)} /></label>
<button onclick={() => replace()}>replace</button> <label><span>Replace with</span><input type="search" title="editor-replace" oninput={e => setReplacement(e.target.value)} /></label>
<button>replace</button>
</form>; </form>;
}; };