quick and dirty search and replace

This commit is contained in:
Chris Kruining 2025-02-25 17:02:11 +11:00
parent fc22ce6027
commit 5f6138d30b
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
6 changed files with 36 additions and 83 deletions

View file

@ -62,7 +62,7 @@
"build": "vinxi build", "build": "vinxi build",
"start": "vinxi start", "start": "vinxi start",
"version": "vinxi version", "version": "vinxi version",
"test": "vitest --coverage --browser=chromium", "test": "vitest --coverage",
"test:ci": "vitest run" "test:ci": "vitest run"
} }
} }

View file

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

View file

@ -4,6 +4,7 @@ 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;
@ -17,9 +18,11 @@ 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] = createEditContext(editorRef, () => source.out); const [text, { mutate }] = createEditContext(editorRef, () => source.out);
createEffect(() => { createEffect(() => {
source.out = text(); source.out = text();
@ -41,9 +44,17 @@ export function Textarea(props: TextareaProps) {
createHighlights(ref, 'search-results', errors); createHighlights(ref, 'search-results', errors);
})); }));
const replace = () => {
mutate(text => text.replaceAll(source.query, replacement()));
};
return <> return <>
<Suggestions /> <div class={css.search}>
<input class={css.search} type="search" title={`${props.title ?? ''}-search`} oninput={e => source.query = e.target.value} /> <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 <div
ref={setEditorRef} ref={setEditorRef}
class={`${css.textarea} ${props.class}`} class={`${css.textarea} ${props.class}`}
@ -58,82 +69,6 @@ export function Textarea(props: TextareaProps) {
</>; </>;
} }
const Suggestions: Component = () => {
const [selection] = createSelection();
const [suggestionRef, setSuggestionRef] = createSignal<HTMLElement>();
const [suggestions, setSuggestions] = createSignal<string[]>([]);
const marker = createMemo(() => {
if (isServer) {
return;
}
const [n] = selection();
const s = window.getSelection();
if (n === null || s === null || s.rangeCount < 1) {
return;
}
return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined;
});
createEffect<HTMLElement | undefined>((prev) => {
if (prev) {
prev.style.setProperty('anchor-name', null);
}
const m = marker();
const ref = untrack(() => suggestionRef()!);
if (m === undefined) {
ref.hidePopover();
return;
}
m.style.setProperty('anchor-name', '--suggestions');
ref.showPopover();
ref.focus()
return m;
});
createEffect(() => {
marker();
setSuggestions(Array(Math.ceil(Math.random() * 5)).fill('').map((_, i) => `suggestion ${i}`));
});
const onPointerDown = (e: PointerEvent) => {
marker()?.replaceWith(document.createTextNode(e.target.textContent));
};
const onKeyDown = (e: KeyboardEvent) => {
console.log(e);
}
return <menu ref={setSuggestionRef} class={css.suggestions} popover="manual" onkeydown={onKeyDown}>
<For each={suggestions()}>{
suggestion => <li onpointerdown={onPointerDown}>{suggestion}</li>
}</For>
</menu>;
};
const findMarkerNode = (node: Node | null) => {
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).hasAttribute('data-marker')) {
break;
}
node = node.parentNode;
}
return node;
};
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
queueMicrotask(() => { queueMicrotask(() => {
const nodes = getTextNodes(node); const nodes = getTextNodes(node);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -7,11 +7,14 @@ 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>]; type Editor = [Accessor<string>, { select(range: Range): void, mutate(setter: (text: string) => string): void }];
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) { },
mutate() { },
}];
} }
if (!("EditContext" in window)) { if (!("EditContext" in window)) {
@ -187,7 +190,17 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
} }
}); });
return [createMemo(() => store.text)]; return [
createMemo(() => store.text),
{
select(range: Range) {
updateSelection(range);
},
mutate(setter) {
setStore('text', setter);
},
}];
} }
const equals = (a: Range, b: Range): boolean => { const equals = (a: Range, b: Range): boolean => {

View file

@ -43,6 +43,7 @@ function reportWith(...reporter: CoverageReporter[]): Plugin {
provider: 'playwright', provider: 'playwright',
enabled: true, enabled: true,
headless: true, headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }] instances: [{ browser: 'chromium' }]
}; };
} }