From 5a813627ea403763ded82c392ca7397c90459f2c Mon Sep 17 00:00:00 2001
From: Chris Kruining
Date: Thu, 13 Mar 2025 16:19:48 +0100
Subject: [PATCH] oooh wow, I overcomplicated this sooooooooo much. just stick
to dom manipulations.
---
src/entry-client.tsx | 3 +
src/features/editor/ast.ts | 13 +-
src/features/editor/context.ts | 161 ++++++++----------
src/features/editor/editor.tsx | 23 ++-
src/features/editor/index.tsx | 3 -
src/features/editor/map.ts | 150 ++--------------
src/features/editor/state.ts | 41 -----
src/features/file/helpers.ts | 3 -
.../(editor)/experimental/editor.module.css | 5 +
src/routes/(editor)/experimental/editor.tsx | 63 +++----
src/utilities.ts | 6 +
11 files changed, 146 insertions(+), 325 deletions(-)
delete mode 100644 src/features/editor/state.ts
diff --git a/src/entry-client.tsx b/src/entry-client.tsx
index b613fae..1639f34 100644
--- a/src/entry-client.tsx
+++ b/src/entry-client.tsx
@@ -1,5 +1,8 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
+import { installIntoGlobal } from "iterator-helpers-polyfill";
import 'solid-devtools';
+installIntoGlobal();
+
mount(() => , document.body);
diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts
index ea08fb9..1ff0afa 100644
--- a/src/features/editor/ast.ts
+++ b/src/features/editor/ast.ts
@@ -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);
diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts
index a612280..1fcbfa9 100644
--- a/src/features/editor/context.ts
+++ b/src/features/editor/context.ts
@@ -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, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }];
+type Editor = { select: SelectFunction, readonly selection: Accessor };
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, value: Accessor): Editor {
if (isServer) {
- return [value, {
+ return {
select() { },
- mutate() { },
selection: () => undefined,
- }];
+ };
}
if (!("EditContext" in window)) {
@@ -46,8 +33,9 @@ export function createEditor(ref: Accessor, 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({
isComposing: false,
selection: undefined,
@@ -58,13 +46,30 @@ export function createEditor(ref: Accessor, value: Accessor
selectionBounds: new DOMRect(),
});
+ createEffect(on(mutations, () => {
+ const selection = store.selection;
+
+ if (selection === undefined) {
+ return
+ }
+
+ queueMicrotask(() => {
+ console.log(selection);
+
+ updateSelection(selection);
+ });
+ }));
+
createEventListenerMap(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, 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, 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, value: Accessor
if (e.key === 'Tab') {
e.preventDefault();
- updateText(start, end, ' ');
+ context.updateText(start, end, ' ');
} else if (e.key === 'Enter') {
- updateText(start, end, '
');
+ context.updateText(start, end, '
');
}
},
});
@@ -177,7 +184,8 @@ export function createEditor(ref: Accessor, 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, value: Accessor
return el;
});
- createEffect(() => {
+ return {
+ select(range: Range) {
+ updateSelection(range);
+ },
+
+ selection: createMemo(() => {
+ return store.selection;
+ }),
+ };
+}
+
+const observe = (node: Accessor): Accessor => {
+ const [mutations, setMutations] = createSignal([]);
+
+ 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(() => {
- 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'];
diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx
index 4f0d6d3..67b3fab 100644
--- a/src/features/editor/editor.tsx
+++ b/src/features/editor/editor.tsx
@@ -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;
- readonly selection: Accessor;
+ readonly selection: Accessor;
readonly source: Source;
select: SelectFunction;
- mutate: MutateFunction;
}
interface EditorContextProps extends Record {
@@ -20,16 +19,12 @@ interface EditorContextProps extends Record {
const [EditorProvider, useEditor] = createContextProvider((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 source.out),
select,
- mutate,
source,
selection,
};
@@ -54,7 +48,6 @@ const [EditorProvider, useEditor] = createContextProvider 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 }) {
const { text } = useEditor();
+ createEffect(() => {
+ text();
+
+ console.error('rerendering');
+ });
+
return ;
}
diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx
index 6a7741c..d0068de 100644
--- a/src/features/editor/index.tsx
+++ b/src/features/editor/index.tsx
@@ -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';
\ No newline at end of file
diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts
index c9725cb..9923233 100644
--- a/src/features/editor/map.ts
+++ b/src/features/editor/map.ts
@@ -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, ast: Accessor) {
- // 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 => {
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): Accessor => {
- const [mutations, setMutations] = createSignal([]);
-
- 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;
};
\ No newline at end of file
diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts
deleted file mode 100644
index d5172b4..0000000
--- a/src/features/editor/state.ts
+++ /dev/null
@@ -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): 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);
\ No newline at end of file
diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts
index a4b68bd..17a4801 100644
--- a/src/features/file/helpers.ts
+++ b/src/features/file/helpers.ts
@@ -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 { }
interface Contents extends Map> { }
diff --git a/src/routes/(editor)/experimental/editor.module.css b/src/routes/(editor)/experimental/editor.module.css
index f756d0b..6c4c873 100644
--- a/src/routes/(editor)/experimental/editor.module.css
+++ b/src/routes/(editor)/experimental/editor.module.css
@@ -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);
}
diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx
index 3b18802..d7aa2a2 100644
--- a/src/routes/(editor)/experimental/editor.tsx
+++ b/src/routes/(editor)/experimental/editor.tsx
@@ -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
;
diff --git a/src/utilities.ts b/src/utilities.ts
index 5af38dc..bc36f57 100644
--- a/src/utilities.ts
+++ b/src/utilities.ts
@@ -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)}`;
};