stabalized the index map, now the selection is lost on rerenders again :/

This commit is contained in:
Chris Kruining 2025-03-17 16:31:11 +01:00
parent 5a813627ea
commit 41a1ef0dbb
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
8 changed files with 91 additions and 86 deletions

46
.vscode/launch.json vendored
View file

@ -6,7 +6,7 @@
"request": "launch", "request": "launch",
"name": "Start dev", "name": "Start dev",
// The path to a JavaScript or TypeScript file to run. // The path to a JavaScript or TypeScript file to run.
"program": "${file}", "program": "entry-server.tsx",
// The arguments to pass to the program, if any. // The arguments to pass to the program, if any.
"args": [], "args": [],
// The working directory of the program. // The working directory of the program.
@ -15,40 +15,9 @@
"env": {}, "env": {},
// If the environment variables should not be inherited from the parent process. // If the environment variables should not be inherited from the parent process.
"strictEnv": false, "strictEnv": false,
// If the program should be run in watch mode.
// This is equivalent to passing `--watch` to the `bun` executable.
// You can also set this to "hot" to enable hot reloading using `--hot`.
"watchMode": false, "watchMode": false,
// If the debugger should stop on the first line of the program. // If the debugger should stop on the first line of the program.
"stopOnEntry": false, "stopOnEntry": true,
// If the debugger should be disabled. (for example, breakpoints will not be hit)
"noDebug": false,
// The path to the `bun` executable, defaults to your `PATH` environment variable.
"runtime": "bun",
// The arguments to pass to the `bun` executable, if any.
// Unlike `args`, these are passed to the executable itself, not the program.
"runtimeArgs": [],
},
{
"type": "bun",
"request": "launch",
"name": "Run tests",
// The path to a JavaScript or TypeScript file to run.
"program": "${file}",
// The arguments to pass to the program, if any.
"args": [],
// The working directory of the program.
"cwd": "${workspaceFolder}",
// The environment variables to pass to the program.
"env": {},
// If the environment variables should not be inherited from the parent process.
"strictEnv": false,
// If the program should be run in watch mode.
// This is equivalent to passing `--watch` to the `bun` executable.
// You can also set this to "hot" to enable hot reloading using `--hot`.
"watchMode": false,
// If the debugger should stop on the first line of the program.
"stopOnEntry": false,
// If the debugger should be disabled. (for example, breakpoints will not be hit) // If the debugger should be disabled. (for example, breakpoints will not be hit)
"noDebug": false, "noDebug": false,
// The path to the `bun` executable, defaults to your `PATH` environment variable. // The path to the `bun` executable, defaults to your `PATH` environment variable.
@ -56,17 +25,18 @@
// The arguments to pass to the `bun` executable, if any. // The arguments to pass to the `bun` executable, if any.
// Unlike `args`, these are passed to the executable itself, not the program. // Unlike `args`, these are passed to the executable itself, not the program.
"runtimeArgs": [ "runtimeArgs": [
"run", "--bun",
"test" "--inspect",
"dev"
], ],
}, },
{ {
"type": "bun", "type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "attach", "request": "attach",
"name": "Attach to Bun", "name": "Attach Bun",
// The URL of the WebSocket inspector to attach to.
// This value can be retreived by using `bun --inspect`.
"url": "ws://localhost:6499/", "url": "ws://localhost:6499/",
"stopOnEntry": true
} }
] ]
} }

View file

@ -1,10 +1,6 @@
{ {
// The path to the `bun` executable. // The path to the `bun` executable.
"bun.runtime": "/path/to/bun", "bun.runtime": "/path/to/bun",
"bun.debugTerminal": { "bun.debugTerminal.enabled": true,
// If support for Bun should be added to the default "JavaScript Debug Terminal". "bun.debugTerminal.stopOnEntry": true
"enabled": true,
// If the debugger should stop on the first line of the program.
"stopOnEntry": false,
}
} }

View file

@ -1,6 +1,6 @@
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener";
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount, Setter } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { createMap } from './map'; import { createMap } from './map';
import { unified } from "unified"; import { unified } from "unified";
@ -10,6 +10,7 @@ export type SelectFunction = (range: Range) => void;
type Editor = { select: SelectFunction, readonly selection: Accessor<Range | undefined> }; type Editor = { select: SelectFunction, readonly selection: Accessor<Range | undefined> };
interface EditorStoreType { interface EditorStoreType {
text: string;
isComposing: boolean; isComposing: boolean;
selection: Range | undefined; selection: Range | undefined;
characterBounds: DOMRect[]; characterBounds: DOMRect[];
@ -17,7 +18,7 @@ interface EditorStoreType {
selectionBounds: DOMRect; selectionBounds: DOMRect;
} }
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor { export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>, setValue: (next: string) => any): Editor {
if (isServer) { if (isServer) {
return { return {
select() { }, select() { },
@ -29,14 +30,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
throw new Error('`EditContext` is not implemented'); throw new Error('`EditContext` is not implemented');
} }
const context = new EditContext({
text: value(),
});
const mutations = observe(ref);
const ast = createMemo(() => parse(value()));
const indexMap = createMap(ref, ast);
const [store, setStore] = createStore<EditorStoreType>({ const [store, setStore] = createStore<EditorStoreType>({
text: value(),
isComposing: false, isComposing: false,
selection: undefined, selection: undefined,
@ -46,20 +41,54 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
selectionBounds: new DOMRect(), selectionBounds: new DOMRect(),
}); });
createEffect(on(mutations, () => { const context = new EditContext({
const selection = store.selection; text: store.text,
});
if (selection === undefined) { const mutations = observe(ref);
return const ast = createMemo(() => parse(store.text));
} const indexMap = createMap(ref, ast);
createEffect(() => {
setValue(store.text);
});
// createEffect(() => {
// const selection = store.selection;
// if (!selection) {
// return;
// }
// console.log(indexMap.query(selection));
// });
createEffect(on(() => [ref(), ast()], () => {
console.log('pre rerender?');
const selection = store.selection;
const indices = selection ? indexMap.query(selection) : [];
queueMicrotask(() => { queueMicrotask(() => {
console.log(selection); console.log('post rerender?');
console.log(indices);
updateSelection(selection);
}); });
})); }));
createEffect(on(value, value => {
if (value !== store.text) {
setStore('text', value);
}
}));
createEffect(on(mutations, ([root, mutations]) => {
const text = (root! as HTMLElement).innerHTML;
if (text !== store.text) {
context.updateText(0, context.text.length, text);
setStore('text', context.text);
}
}));
createEventListenerMap<any>(context, { createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) { textupdate(e: TextUpdateEvent) {
const selection = store.selection; const selection = store.selection;
@ -68,6 +97,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
return; return;
} }
selection.extractContents();
selection.insertNode(document.createTextNode(e.text)); selection.insertNode(document.createTextNode(e.text));
selection.collapse(); selection.collapse();
}, },
@ -100,8 +130,6 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
function updateSelection(range: Range) { function updateSelection(range: Range) {
const [start, end] = indexMap.query(range); const [start, end] = indexMap.query(range);
console.log(start, end, range);
if (!start || !end) { if (!start || !end) {
return; return;
} }

View file

@ -1,5 +1,5 @@
import { createContextProvider } from "@solid-primitives/context"; import { createContextProvider } from "@solid-primitives/context";
import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter, untrack } from "solid-js";
import { createEditor, SelectFunction } from "./context"; import { createEditor, SelectFunction } from "./context";
import { createSource, Source } from "../source"; import { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection"; import { getTextNodes } from "@solid-primitives/selection";
@ -19,7 +19,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 { select, selection } = createEditor(props.ref, () => source.out); const { select, selection } = createEditor(props.ref, () => source.out, next => source.out = next);
createEffect(() => { createEffect(() => {
props.oninput?.(source.in); props.oninput?.(source.in);
@ -38,7 +38,7 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
})); }));
return { return {
text: createMemo(() => source.out), text: () => source.out,
select, select,
source, source,
selection, selection,
@ -65,11 +65,7 @@ export function Editor(props: ParentProps<{ value: string, oninput?: (value: str
function Content(props: { ref: Setter<Element | undefined> }) { function Content(props: { ref: Setter<Element | undefined> }) {
const { text } = useEditor(); const { text } = useEditor();
createEffect(() => { createEffect(on(text, () => console.error('rerendering')));
text();
console.error('rerendering');
});
return <div ref={props.ref} innerHTML={text()} />; return <div ref={props.ref} innerHTML={text()} />;
} }

View file

@ -8,17 +8,21 @@ export type IndexMap = IndexNode[];
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) { export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) {
const mapping = createMemo(() => { const [mapping, setMapping] = createSignal(new WeakMap());
createEffect(() => {
const node = root(); const node = root();
const tree = ast(); const tree = ast();
if (node === undefined) { if (node === undefined) {
return new WeakMap(); return;
} }
console.warn('recalculating map'); // Delay the recalculation a bit to give other code a chance to update the DOM.
// This -hopefully- prevents the map from getting out of sync
return createMapping(node, tree); queueMicrotask(() => {
setMapping(createMapping(node, tree));
});
}); });
return { return {

View file

@ -31,15 +31,15 @@ this is *a string* that contains italicized text
export default function Formatter(props: {}) { export default function Formatter(props: {}) {
const [value, setValue] = createSignal(tempVal); const [value, setValue] = createSignal(tempVal);
const onInput = debounce((e: InputEvent) => { const onInput = (e: InputEvent) => {
setValue((e.target! as HTMLTextAreaElement).value); setValue((e.target! as HTMLTextAreaElement).value);
}, 300); };
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}> <div class={css.editor}>
<Editor value={untrack(value)} oninput={setValue}> <Editor value={value()} oninput={setValue}>
<Toolbar /> <Toolbar />
<SearchAndReplace /> <SearchAndReplace />
</Editor> </Editor>
@ -48,10 +48,12 @@ export default function Formatter(props: {}) {
} }
function Toolbar() { function Toolbar() {
const { selection } = useEditor();
const bold = () => { const bold = () => {
const range = window.getSelection()!.getRangeAt(0); const range = untrack(selection)!;
// const { startContainer, startOffset, endContainer, endOffset, commonAncestorContainer } = range;
// console.log(startContainer, startOffset, endContainer, endOffset, commonAncestorContainer); console.log(range);
if (range.startContainer.nodeType !== Node.TEXT_NODE) { if (range.startContainer.nodeType !== Node.TEXT_NODE) {
return; return;
@ -61,6 +63,13 @@ function Toolbar() {
return; return;
} }
// Trim whitespace
{
const text = range.toString();
range.setStart(range.startContainer, range.startOffset + (text.match(/^\s+/)?.[0].length ?? 0));
range.setEnd(range.endContainer, range.endOffset - (text.match(/\s+$/)?.[0].length ?? 0));
}
const fragment = range.extractContents(); const fragment = range.extractContents();
if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') { if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') {
@ -72,6 +81,7 @@ function Toolbar() {
strong.append(fragment); strong.append(fragment);
range.insertNode(strong); range.insertNode(strong);
range.selectNode(strong);
} }
}; };
@ -87,7 +97,7 @@ function Toolbar() {
} }
function SearchAndReplace() { function SearchAndReplace() {
const { mutate, source } = useEditor(); const { source } = useEditor();
const [replacement, setReplacement] = createSignal(''); const [replacement, setReplacement] = createSignal('');
const [term, setTerm] = createSignal(''); const [term, setTerm] = createSignal('');
const [caseInsensitive, setCaseInsensitive] = createSignal(true); const [caseInsensitive, setCaseInsensitive] = createSignal(true);
@ -104,7 +114,9 @@ function SearchAndReplace() {
const form = e.target as HTMLFormElement; const form = e.target as HTMLFormElement;
form.reset(); form.reset();
mutate(text => text.replaceAll(query(), replacement())); console.log(source.queryResults);
// mutate(text => text.replaceAll(query(), replacement()));
}; };
return <form on:submit={replace} class={css.search} popover="manual"> return <form on:submit={replace} class={css.search} popover="manual">

View file

@ -17,7 +17,6 @@
"@vitest/browser/providers/playwright", "@vitest/browser/providers/playwright",
"vinxi/types/client", "vinxi/types/client",
"vite-plugin-solid-svg/types-component-solid", "vite-plugin-solid-svg/types-component-solid",
"vite-plugin-pwa/solid",
"bun-types" "bun-types"
], ],
"isolatedModules": true, "isolatedModules": true,