oooh wow, I overcomplicated this sooooooooo much. just stick to dom manipulations.

This commit is contained in:
Chris Kruining 2025-03-13 16:19:48 +01:00
parent e88d727d8e
commit 5a813627ea
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
11 changed files with 146 additions and 325 deletions

View file

@ -1,5 +1,8 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
import { installIntoGlobal } from "iterator-helpers-polyfill";
import 'solid-devtools';
installIntoGlobal();
mount(() => <StartClient />, document.body);

View file

@ -13,17 +13,21 @@ interface SplitPoint {
export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => {
const result: RootContent[][] = [];
let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : [];
// console.log(Object.groupBy(splitPoints, p => hash(p.node)));
let lastNode;
let accumulatedOffset = 0;
for (const { node, offset } of splitPoints) {
if (lastNode !== node) {
accumulatedOffset = 0;
}
const index = remaining.findIndex(c => find(c, n => equals(n, node)));
if (index === -1) {
throw new Error('The tree does not contain the given node');
}
const [targetLeft, targetRight] = splitNode(remaining[index], node, offset);
const [targetLeft, targetRight] = splitNode(remaining[index], node, offset - accumulatedOffset);
const left = remaining.slice(0, index);
const right = remaining.slice(index + 1);
@ -38,6 +42,9 @@ export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][
remaining = right;
result.push(left);
lastNode = node;
accumulatedOffset += offset;
}
result.push(remaining);

View file

@ -1,16 +1,13 @@
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener";
import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js";
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js";
import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web";
import { createMap } from './map';
import { lazy, splice } from "~/utilities";
import { createState } from "./state";
import type { Parent, Root, Text } from 'hast';
import findAncestor from "unist-util-ancestor";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
export type SelectFunction = (range: Range) => void;
export type MutateFunction = (setter: (ast: Root) => Root) => void;
type Editor = [Accessor<string>, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor<Index_Range | undefined> }];
type Editor = { select: SelectFunction, readonly selection: Accessor<Range | undefined> };
interface EditorStoreType {
isComposing: boolean;
@ -20,22 +17,12 @@ interface EditorStoreType {
selectionBounds: DOMRect;
}
export interface Index_Range {
startNode: Text;
startOffset: number;
endNode: Text;
endOffset: number;
commonAncestor: () => Parent;
}
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
if (isServer) {
return [value, {
return {
select() { },
mutate() { },
selection: () => undefined,
}];
};
}
if (!("EditContext" in window)) {
@ -46,8 +33,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
text: value(),
});
const state = createState(value);
const indexMap = createMap(() => ref()!, () => state.ast);
const mutations = observe(ref);
const ast = createMemo(() => parse(value()));
const indexMap = createMap(ref, ast);
const [store, setStore] = createStore<EditorStoreType>({
isComposing: false,
selection: undefined,
@ -58,13 +46,30 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
selectionBounds: new DOMRect(),
});
createEffect(on(mutations, () => {
const selection = store.selection;
if (selection === undefined) {
return
}
queueMicrotask(() => {
console.log(selection);
updateSelection(selection);
});
}));
createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) {
const { updateRangeStart: start, updateRangeEnd: end, text } = e;
const selection = store.selection;
setStore('text', `${store.text.slice(0, start)}${text}${store.text.slice(end)}`);
if (!selection) {
return;
}
context.updateSelection(start + text.length, start + text.length);
selection.insertNode(document.createTextNode(e.text));
selection.collapse();
},
compositionstart() {
@ -88,20 +93,20 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
},
});
function updateText(start: number, end: number, text: string) {
context.updateText(start, end, text);
state.text = splice(state.text, start, end, text);
// context.updateSelection(start + text.length, start + text.length);
}
function updateControlBounds() {
context.updateControlBounds(ref()!.getBoundingClientRect());
}
function updateSelection(range: Range) {
context.updateSelection(...indexMap.toHtmlIndices(range));
const [start, end] = indexMap.query(range);
console.log(start, end, range);
if (!start || !end) {
return;
}
context.updateSelection(start.start + range.startOffset, end.start + range.endOffset);
context.updateSelectionBounds(range.getBoundingClientRect());
setStore('selection', range);
@ -158,6 +163,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
// keyCode === 229 is a special code that indicates an IME event.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime
if (e.keyCode === 229) {
console.log(e);
return;
}
@ -167,9 +174,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
if (e.key === 'Tab') {
e.preventDefault();
updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;');
context.updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;');
} else if (e.key === 'Enter') {
updateText(start, end, '</p><p>&nbsp;');
context.updateText(start, end, '</p><p>&nbsp;');
}
},
});
@ -177,7 +184,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
onMount(() => {
updateControlBounds();
updateSelection(indexMap.fromHtmlIndices(40, 60))
// updateSelection(indexMap.fromHtmlIndices(40, 60))
// updateSelection(indexMap.fromHtmlIndices(599, 603))
});
createEffect((last?: Element) => {
@ -196,70 +204,43 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
return el;
});
createEffect(() => {
return {
select(range: Range) {
updateSelection(range);
},
selection: createMemo<Range | undefined>(() => {
return store.selection;
}),
};
}
const observe = (node: Accessor<Node | undefined>): Accessor<readonly [Node | undefined, MutationRecord[]]> => {
const [mutations, setMutations] = createSignal<MutationRecord[]>([]);
const observer = new MutationObserver(records => {
setMutations(records);
});
createEffect(() => {
updateText(0, -0, value());
});
const n = node();
createEffect(() => {
state.text;
observer.disconnect();
if (document.activeElement === untrack(ref)) {
queueMicrotask(() => {
updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd));
});
if (n) {
observer.observe(n, { characterData: true, subtree: true, childList: true });
}
});
return [
createMemo(() => state.text),
{
select(range: Range) {
updateSelection(range);
},
onCleanup(() => {
observer.disconnect();
});
mutate(setter) {
const [start, end] = indexMap.toTextIndices(store.selection!);
return createMemo(() => [node(), mutations()] as const);
};
state.ast = setter(state.ast);
setTimeout(() => {
console.log('RESTORING SELECTION')
const range = indexMap.fromTextIndices(start, end);
console.log(start, end, range);
updateSelection(range);
}, 100);
},
selection: createMemo<Index_Range | undefined>(() => {
const selection = store.selection;
if (!selection) {
return undefined;
}
const [start, end] = indexMap.query(selection);
if (!start || !end) {
return undefined;
}
return {
startNode: start.node,
startOffset: selection.startOffset,
endNode: end.node,
endOffset: selection.endOffset,
commonAncestor: lazy(() => findAncestor(untrack(() => state.ast), [start.node, end.node]) as Parent),
}
}),
}];
}
const parseProcessor = unified().use(rehypeParse)
const parse = (text: string) => parseProcessor.parse(text);
const equals = (a: Range, b: Range): boolean => {
const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer'];

View file

@ -1,15 +1,14 @@
import { createContextProvider } from "@solid-primitives/context";
import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js";
import { createEditor, Index_Range, MutateFunction, SelectFunction } from "./context";
import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js";
import { createEditor, SelectFunction } from "./context";
import { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection";
interface EditorContextType {
readonly text: Accessor<string>;
readonly selection: Accessor<Index_Range | undefined>;
readonly selection: Accessor<Range | undefined>;
readonly source: Source;
select: SelectFunction;
mutate: MutateFunction;
}
interface EditorContextProps extends Record<string, unknown> {
@ -20,16 +19,12 @@ interface EditorContextProps extends Record<string, unknown> {
const [EditorProvider, useEditor] = createContextProvider<EditorContextType, EditorContextProps>((props) => {
const source = createSource(() => props.value);
const [text, { select, mutate, selection }] = createEditor(props.ref, () => source.out);
const { select, selection } = createEditor(props.ref, () => source.out);
createEffect(() => {
props.oninput?.(source.in);
});
createEffect(() => {
source.out = text();
});
createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'spelling-error', errors);
}));
@ -43,9 +38,8 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
}));
return {
text,
text: createMemo(() => source.out),
select,
mutate,
source,
selection,
};
@ -54,7 +48,6 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
selection: () => undefined,
source: {} as Source,
select() { },
mutate() { },
});
export { useEditor };
@ -72,6 +65,12 @@ export function Editor(props: ParentProps<{ value: string, oninput?: (value: str
function Content(props: { ref: Setter<Element | undefined> }) {
const { text } = useEditor();
createEffect(() => {
text();
console.error('rerendering');
});
return <div ref={props.ref} innerHTML={text()} />;
}

View file

@ -1,6 +1,3 @@
export type { Index_Range } from './context';
export { createEditor as createEditContext } from './context';
export { Editor, useEditor } from './editor';
export { splitBy, createElement, mergeNodes } from './ast';

View file

@ -7,159 +7,37 @@ export type IndexNode = { node: Text, dom: Node, text: { start: number, end: num
export type IndexMap = IndexNode[];
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) {
// Observe the element so that the references to the nodes in the indices are updated if the DOM is changed
const latestMutations = observe(root);
const indices = createMemo(() => {
const [node] = latestMutations();
const mapping = createMemo(() => {
const node = root();
const tree = ast();
if (node === undefined) {
return [];
return new WeakMap();
}
return createIndices(node, ast());
console.warn('recalculating map');
return createMapping(node, tree);
});
return {
query(range: Range): [IndexNode | undefined, IndexNode | undefined] {
query: (range: Range) => {
return [
indices().find(({ dom }) => dom === range.startContainer),
indices().find(({ dom }) => dom === range.endContainer),
mapping().get(range.startContainer),
mapping().get(range.endContainer),
];
},
atHtmlPosition(start: number, end: number): IndexRange {
const startNode = indices().find(({ html }) => html.start <= start && html.end >= start);
const endNode = indices().find(({ html }) => html.start <= end && html.end >= end);
if (!startNode || !endNode) {
return [undefined, undefined];
}
return [startNode, endNode];
},
atTextPosition(start: number, end: number): IndexRange {
const startNode = indices().find(({ text }) => text.start <= start && text.end >= start);
const endNode = indices().find(({ text }) => text.start <= end && text.end >= end);
if (!startNode || !endNode) {
return [undefined, undefined];
}
return [startNode, endNode];
},
toTextIndices(range: Range): [number, number] {
const [startNode, endNode] = this.query(range);
return [
startNode ? (startNode.text.start + range.startOffset) : -1,
endNode ? (endNode.text.start + range.endOffset) : -1
];
},
toHtmlIndices(range: Range): [number, number] {
const [startNode, endNode] = this.query(range);
return [
startNode ? (startNode.html.start + range.startOffset) : -1,
endNode ? (endNode.html.start + range.endOffset) : -1
];
},
fromTextIndices(start: number, end: number): Range {
const [startNode, endNode] = this.atTextPosition(start, end);
const range = new Range();
if (startNode) {
const offset = start - startNode.text.start;
range.setStart(startNode.dom, offset);
}
if (endNode) {
const offset = end - endNode.text.start;
console.log('end offset', endNode);
range.setEnd(endNode.dom, offset);
}
return range;
},
fromHtmlIndices(start: number, end: number): Range {
const [startNode, endNode] = this.atHtmlPosition(start, end);
const range = new Range();
if (startNode) {
const offset = start - startNode.html.start;
range.setStart(startNode.dom, offset);
}
if (endNode) {
const offset = end - endNode.html.start;
range.setEnd(endNode.dom, offset);
}
return range;
},
};
}
const createIndices = (root: Node, ast: Root): IndexMap => {
const createMapping = (root: Node, ast: Root): WeakMap<Node, { start: number, end: number }> => {
const nodes = getTextNodes(root);
const indices: IndexMap = [];
const map = new WeakMap();
console.log(ast);
let index = 0;
visit(ast, (n): n is Text => n.type === 'text', (node) => {
const { position, value } = node as Text;
const end = index + value.length;
const dom = nodes.shift()!;
console.log({ value, text: dom?.textContent, dom });
// if (value.includes('ntains bolded text')) {
// console.log(value, dom.textContent, { node, dom });
// }
if (position) {
indices.push({ node, dom, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } });
}
index = end;
map.set(nodes.shift()!, { start: node.position!.start.offset, end: node.position!.end.offset, text: node.value })
});
return indices;
};
const observe = (node: Accessor<Node | undefined>): Accessor<readonly [Node | undefined, MutationRecord[]]> => {
const [mutations, setMutations] = createSignal<MutationRecord[]>([]);
const observer = new MutationObserver(records => {
setMutations(records);
});
createEffect(() => {
const n = node();
observer.disconnect();
if (n) {
observer.observe(n, { characterData: true, subtree: true, childList: true });
}
});
onCleanup(() => {
observer.disconnect();
});
return createMemo(() => [node(), mutations()] as const);
return map;
};

View file

@ -1,41 +0,0 @@
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
import { Accessor, createSignal } from "solid-js";
import { unified } from "unified";
import type { Root } from 'hast';
export interface State {
text: string;
ast: Root;
}
export const createState = (value: Accessor<string>): State => {
const [text, setText] = createSignal(value());
const [ast, setAst] = createSignal(parse(value()));
return {
get text() {
return text();
},
set text(next: string) {
setText(next);
setAst(parse(next));
},
get ast() {
return ast();
},
set ast(next: Root) {
setText(stringify(next));
setAst(next);
},
};
};
const stringifyProcessor = unified().use(rehypeStringify)
const parseProcessor = unified().use(rehypeParse)
const stringify = (root: Root) => stringifyProcessor.stringify(root);
const parse = (text: string) => parseProcessor.parse(text);

View file

@ -2,11 +2,8 @@ import { Accessor, createEffect, from, createSignal } from "solid-js";
import { json } from "./parser";
import { filter } from "~/utilities";
import { isServer } from "solid-js/web";
import { installIntoGlobal } from 'iterator-helpers-polyfill';
import { debounce } from "@solid-primitives/scheduled";
installIntoGlobal();
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
interface Contents extends Map<string, Map<string, string>> { }

View file

@ -20,6 +20,11 @@
background-color: transparent;
}
& ::highlight(debug) {
text-decoration: double underline;
text-decoration-color: cornflowerblue;
}
& ::highlight(search-results) {
background-color: var(--secondary-900);
}

View file

@ -1,9 +1,8 @@
import { createEffect, createMemo, createSignal, untrack } from "solid-js";
import { createEffect, createMemo, createSignal, onMount, untrack } from "solid-js";
import { debounce } from "@solid-primitives/scheduled";
import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor";
import { visitParents } from "unist-util-visit-parents";
import type * as hast from 'hast';
import { Editor, useEditor } from "~/features/editor";
import css from './editor.module.css';
import { assert } from "~/utilities";
const tempVal = `
# Header
@ -49,49 +48,39 @@ export default function Formatter(props: {}) {
}
function Toolbar() {
const { mutate, selection } = useEditor();
const trimWhitespaceOn = ({ startNode: startContainer, endNode: endContainer, startOffset, endOffset, ...rest }: Index_Range): Index_Range => {
const matchStart = startContainer.value.slice(startOffset).match(/^(\s+).*?$/);
const matchEnd = endContainer.value.slice(0, endOffset).match(/^.*?(\s+)$/);
return {
startNode: startContainer,
startOffset: startOffset + (matchStart?.[1].length ?? 0),
endNode: endContainer,
endOffset: endOffset - (matchEnd?.[1].length ?? 0),
...rest
};
};
const bold = () => {
const range = selection();
const range = window.getSelection()!.getRangeAt(0);
// const { startContainer, startOffset, endContainer, endOffset, commonAncestorContainer } = range;
// console.log(startContainer, startOffset, endContainer, endOffset, commonAncestorContainer);
if (!range) {
if (range.startContainer.nodeType !== Node.TEXT_NODE) {
return;
}
mutate((ast) => {
const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range);
if (range.endContainer.nodeType !== Node.TEXT_NODE) {
return;
}
const [left, toBold, right] = splitBy(commonAncestor(), [
{ node: startNode, offset: startOffset },
{ node: endNode, offset: endOffset },
]);
const fragment = range.extractContents();
console.log(left, toBold, right);
const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent;
// THIS IS WHERE I LEFT OFF
// AST needs to be clean!!!!
commonAncestor().children = [...left, boldedElement, ...right];
return ast;
});
if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') {
range.selectNode(range.commonAncestorContainer.parentElement);
range.insertNode(fragment);
}
else {
const strong = document.createElement('strong');
strong.append(fragment);
range.insertNode(strong);
}
};
onMount(() => {
queueMicrotask(() => {
// bold();
});
});
return <div class={css.toolbar}>
<button onclick={bold}>bold</button>
</div>;

View file

@ -1,3 +1,9 @@
export const assert = (assertion: boolean, message: string) => {
if (assertion !== true) {
throw new Error(message);
}
}
export const splice = (subject: string, start: number, end: number, replacement: string) => {
return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`;
};