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 // @refresh reload
import { mount, StartClient } from "@solidjs/start/client"; import { mount, StartClient } from "@solidjs/start/client";
import { installIntoGlobal } from "iterator-helpers-polyfill";
import 'solid-devtools'; import 'solid-devtools';
installIntoGlobal();
mount(() => <StartClient />, document.body); mount(() => <StartClient />, document.body);

View file

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

View file

@ -1,16 +1,13 @@
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; 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 { createStore } 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 { lazy, splice } from "~/utilities"; import { unified } from "unified";
import { createState } from "./state"; import rehypeParse from "rehype-parse";
import type { Parent, Root, Text } from 'hast';
import findAncestor from "unist-util-ancestor";
export type SelectFunction = (range: Range) => void; export type SelectFunction = (range: Range) => void;
export type MutateFunction = (setter: (ast: Root) => Root) => void; type Editor = { select: SelectFunction, readonly selection: Accessor<Range | undefined> };
type Editor = [Accessor<string>, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor<Index_Range | undefined> }];
interface EditorStoreType { interface EditorStoreType {
isComposing: boolean; isComposing: boolean;
@ -20,22 +17,12 @@ interface EditorStoreType {
selectionBounds: DOMRect; 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 { export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
if (isServer) { if (isServer) {
return [value, { return {
select() { }, select() { },
mutate() { },
selection: () => undefined, selection: () => undefined,
}]; };
} }
if (!("EditContext" in window)) { if (!("EditContext" in window)) {
@ -46,8 +33,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
text: value(), text: value(),
}); });
const state = createState(value); const mutations = observe(ref);
const indexMap = createMap(() => ref()!, () => state.ast); const ast = createMemo(() => parse(value()));
const indexMap = createMap(ref, ast);
const [store, setStore] = createStore<EditorStoreType>({ const [store, setStore] = createStore<EditorStoreType>({
isComposing: false, isComposing: false,
selection: undefined, selection: undefined,
@ -58,13 +46,30 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
selectionBounds: new DOMRect(), selectionBounds: new DOMRect(),
}); });
createEffect(on(mutations, () => {
const selection = store.selection;
if (selection === undefined) {
return
}
queueMicrotask(() => {
console.log(selection);
updateSelection(selection);
});
}));
createEventListenerMap<any>(context, { createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) { 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() { 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() { function updateControlBounds() {
context.updateControlBounds(ref()!.getBoundingClientRect()); context.updateControlBounds(ref()!.getBoundingClientRect());
} }
function updateSelection(range: Range) { 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()); context.updateSelectionBounds(range.getBoundingClientRect());
setStore('selection', range); 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. // 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 // https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime
if (e.keyCode === 229) { if (e.keyCode === 229) {
console.log(e);
return; return;
} }
@ -167,9 +174,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;'); context.updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;');
} else if (e.key === 'Enter') { } 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(() => { onMount(() => {
updateControlBounds(); updateControlBounds();
updateSelection(indexMap.fromHtmlIndices(40, 60)) // updateSelection(indexMap.fromHtmlIndices(40, 60))
// updateSelection(indexMap.fromHtmlIndices(599, 603))
}); });
createEffect((last?: Element) => { createEffect((last?: Element) => {
@ -196,70 +204,43 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
return el; 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(() => { createEffect(() => {
updateText(0, -0, value()); const n = node();
});
createEffect(() => { observer.disconnect();
state.text;
if (document.activeElement === untrack(ref)) { if (n) {
queueMicrotask(() => { observer.observe(n, { characterData: true, subtree: true, childList: true });
updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd));
});
} }
}); });
return [ onCleanup(() => {
createMemo(() => state.text), observer.disconnect();
{ });
select(range: Range) {
updateSelection(range);
},
mutate(setter) { return createMemo(() => [node(), mutations()] as const);
const [start, end] = indexMap.toTextIndices(store.selection!); };
state.ast = setter(state.ast); const parseProcessor = unified().use(rehypeParse)
const parse = (text: string) => parseProcessor.parse(text);
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 equals = (a: Range, b: Range): boolean => { const equals = (a: Range, b: Range): boolean => {
const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer']; const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer'];

View file

@ -1,15 +1,14 @@
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, Index_Range, MutateFunction, 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";
interface EditorContextType { interface EditorContextType {
readonly text: Accessor<string>; readonly text: Accessor<string>;
readonly selection: Accessor<Index_Range | undefined>; readonly selection: Accessor<Range | undefined>;
readonly source: Source; readonly source: Source;
select: SelectFunction; select: SelectFunction;
mutate: MutateFunction;
} }
interface EditorContextProps extends Record<string, unknown> { interface EditorContextProps extends Record<string, unknown> {
@ -20,16 +19,12 @@ 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, selection }] = createEditor(props.ref, () => source.out); const { select, selection } = createEditor(props.ref, () => source.out);
createEffect(() => { createEffect(() => {
props.oninput?.(source.in); props.oninput?.(source.in);
}); });
createEffect(() => {
source.out = text();
});
createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => { createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'spelling-error', errors); createHighlights(ref, 'spelling-error', errors);
})); }));
@ -43,9 +38,8 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
})); }));
return { return {
text, text: createMemo(() => source.out),
select, select,
mutate,
source, source,
selection, selection,
}; };
@ -54,7 +48,6 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
selection: () => undefined, selection: () => undefined,
source: {} as Source, source: {} as Source,
select() { }, select() { },
mutate() { },
}); });
export { useEditor }; export { useEditor };
@ -72,6 +65,12 @@ 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(() => {
text();
console.error('rerendering');
});
return <div ref={props.ref} innerHTML={text()} />; 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 { createEditor as createEditContext } from './context';
export { Editor, useEditor } from './editor'; export { Editor, useEditor } from './editor';
export { splitBy, createElement, mergeNodes } from './ast'; 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 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>) {
// Observe the element so that the references to the nodes in the indices are updated if the DOM is changed const mapping = createMemo(() => {
const latestMutations = observe(root); const node = root();
const tree = ast();
const indices = createMemo(() => {
const [node] = latestMutations();
if (node === undefined) { if (node === undefined) {
return []; return new WeakMap();
} }
return createIndices(node, ast()); console.warn('recalculating map');
return createMapping(node, tree);
}); });
return { return {
query(range: Range): [IndexNode | undefined, IndexNode | undefined] { query: (range: Range) => {
return [ return [
indices().find(({ dom }) => dom === range.startContainer), mapping().get(range.startContainer),
indices().find(({ dom }) => dom === range.endContainer), 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 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) => { visit(ast, (n): n is Text => n.type === 'text', (node) => {
const { position, value } = node as Text; map.set(nodes.shift()!, { start: node.position!.start.offset, end: node.position!.end.offset, text: node.value })
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;
}); });
return indices; return map;
};
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);
}; };

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 { json } from "./parser";
import { filter } from "~/utilities"; import { filter } from "~/utilities";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { installIntoGlobal } from 'iterator-helpers-polyfill';
import { debounce } from "@solid-primitives/scheduled"; import { debounce } from "@solid-primitives/scheduled";
installIntoGlobal();
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { } interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
interface Contents extends Map<string, Map<string, string>> { } interface Contents extends Map<string, Map<string, string>> { }

View file

@ -20,6 +20,11 @@
background-color: transparent; background-color: transparent;
} }
& ::highlight(debug) {
text-decoration: double underline;
text-decoration-color: cornflowerblue;
}
& ::highlight(search-results) { & ::highlight(search-results) {
background-color: var(--secondary-900); 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 { debounce } from "@solid-primitives/scheduled";
import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor"; import { Editor, useEditor } from "~/features/editor";
import { visitParents } from "unist-util-visit-parents";
import type * as hast from 'hast';
import css from './editor.module.css'; import css from './editor.module.css';
import { assert } from "~/utilities";
const tempVal = ` const tempVal = `
# Header # Header
@ -49,49 +48,39 @@ export default function Formatter(props: {}) {
} }
function Toolbar() { 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 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; return;
} }
mutate((ast) => { if (range.endContainer.nodeType !== Node.TEXT_NODE) {
const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range); return;
}
const [left, toBold, right] = splitBy(commonAncestor(), [ const fragment = range.extractContents();
{ node: startNode, offset: startOffset },
{ node: endNode, offset: endOffset },
]);
console.log(left, toBold, right); if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') {
const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent; range.selectNode(range.commonAncestorContainer.parentElement);
range.insertNode(fragment);
// THIS IS WHERE I LEFT OFF }
// AST needs to be clean!!!! else {
const strong = document.createElement('strong');
commonAncestor().children = [...left, boldedElement, ...right]; strong.append(fragment);
return ast;
});
range.insertNode(strong);
}
}; };
onMount(() => {
queueMicrotask(() => {
// bold();
});
});
return <div class={css.toolbar}> return <div class={css.toolbar}>
<button onclick={bold}>bold</button> <button onclick={bold}>bold</button>
</div>; </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) => { export const splice = (subject: string, start: number, end: number, replacement: string) => {
return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`; return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`;
}; };