stash
This commit is contained in:
parent
fa6bf5bbac
commit
11aab1dc1a
5 changed files with 106 additions and 38 deletions
|
@ -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),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,13 +32,22 @@
|
||||||
text-decoration-line: grammar-error;
|
text-decoration-line: grammar-error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: block grid;
|
||||||
|
grid: auto 1fr / 100%;
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: block grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
place-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
|
|
||||||
display: block grid;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-auto-flow: row;
|
|
||||||
|
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
|
@ -46,5 +55,14 @@
|
||||||
background-color: var(--surface-700);
|
background-color: var(--surface-700);
|
||||||
border-radius: var(--radii-m);
|
border-radius: var(--radii-m);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
|
|
||||||
|
&:popover-open {
|
||||||
|
display: block grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > label {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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>
|
||||||
|
|
||||||
|
<div class={css.editor}>
|
||||||
<Editor value={value()} oninput={setValue}>
|
<Editor value={value()} oninput={setValue}>
|
||||||
|
<Toolbar />
|
||||||
<SearchAndReplace />
|
<SearchAndReplace />
|
||||||
</Editor>
|
</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>;
|
||||||
};
|
};
|
Loading…
Add table
Add a link
Reference in a new issue