got to a stable point again. next up is comming up with a decent API for modifications

This commit is contained in:
Chris Kruining 2025-02-25 16:21:21 +11:00
parent 4fb7405466
commit fc22ce6027
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
19 changed files with 498 additions and 375 deletions

View file

@ -1,12 +1,13 @@
import { Component, createEffect, createMemo, createSignal, For, on, untrack } from 'solid-js';
import { createSelection, getTextNodes } from '@solid-primitives/selection';
import { isServer } from 'solid-js/web';
import { createEditContext } from '~/features/edit-context';
import { createEditContext } from '~/features/editor';
import { createSource } from '~/features/source';
import css from './textarea.module.css';
interface TextareaProps {
class?: string;
title?: string;
value: string;
lang: string;
placeholder?: string;
@ -20,28 +21,33 @@ export function Textarea(props: TextareaProps) {
const source = createSource(() => props.value);
const [text] = createEditContext(editorRef, () => source.out);
createEffect(() => {
source.out = text();
});
createEffect(() => {
props.oninput?.(source.in);
});
createEffect(on(() => [editorRef(), source.spellingErrors] as const, ([ref, errors]) => {
createEffect(on(() => [editorRef()!, source.spellingErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'spelling-error', errors);
}));
createEffect(on(() => [editorRef(), source.grammarErrors] as const, ([ref, errors]) => {
createEffect(on(() => [editorRef()!, source.grammarErrors] as const, ([ref, errors]) => {
createHighlights(ref, 'grammar-error', errors);
}));
createEffect(on(() => [editorRef(), source.queryResults] as const, ([ref, errors]) => {
createEffect(on(() => [editorRef()!, source.queryResults] as const, ([ref, errors]) => {
createHighlights(ref, 'search-results', errors);
}));
return <>
<Suggestions />
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
<input class={css.search} type="search" title={`${props.title ?? ''}-search`} oninput={e => source.query = e.target.value} />
<div
ref={setEditorRef}
class={`${css.textarea} ${props.class}`}
title={props.title ?? ''}
dir="auto"
lang={props.lang}
innerHTML={text()}

View file

@ -1,3 +0,0 @@
export { createEditContext } from './context';

View file

@ -1,194 +0,0 @@
const WHITESPACE = [" ", "\n", "\t"];
function getOpenTagName(htmlString: string, pos: number) {
let tagName = "";
let char = htmlString.charAt(pos);
while (char !== ">" && char !== " " && char !== "/" && char !== "") {
tagName += char;
char = htmlString.charAt(++pos);
}
return tagName;
}
function getCloseTagName(htmlString: string, pos: number) {
let tagName = "";
let char = htmlString.charAt(pos);
while (char !== ">" && char !== "") {
tagName += char;
char = htmlString.charAt(++pos);
}
return tagName;
}
function getWhiteSpace(htmlString: string, pos: number) {
let whitespace = "";
let char = htmlString.charAt(pos);
while (WHITESPACE.includes(char) && char !== "") {
whitespace += char;
char = htmlString.charAt(++pos);
}
return whitespace;
}
function getAttributeName(htmlString: string, pos: number) {
let attributeName = "";
let char = htmlString.charAt(pos);
while (char !== "=" && char !== " " && char !== ">" && char !== "") {
attributeName += char;
char = htmlString.charAt(++pos);
}
return attributeName;
}
function getAttributeValue(htmlString: string, pos: number, quote: string) {
let attributeValue = "";
let char = htmlString.charAt(pos);
const isAtEnd = (c) => {
if (quote) {
return c === quote || c === "";
}
return c === " " || c === ">" || c === "/" || c === "";
};
while (!isAtEnd(char)) {
attributeValue += char;
char = htmlString.charAt(++pos);
}
return attributeValue;
}
function getText(htmlString: string, pos: number) {
let text = "";
let char = htmlString.charAt(pos);
while (char !== "<" && char !== "") {
text += char;
char = htmlString.charAt(++pos);
}
return text;
}
export function tokenizeHTML(htmlString: string) {
let pos = 0;
let isInTag = false;
let isInAttribute = false;
let isAfterAttributeEqual = false;
const tokens = [];
while (pos < htmlString.length) {
const char = htmlString.charAt(pos);
const nextChar = htmlString.charAt(pos + 1);
if (char === "<" && nextChar !== "/" && !isInTag && !isInAttribute) {
isInTag = true;
tokens.push({ type: "openTagStart", value: "<", pos });
pos++;
const tagName = getOpenTagName(htmlString, pos);
tokens.push({ type: "tagName", value: tagName, pos });
pos += tagName.length;
continue;
}
if (WHITESPACE.includes(char) && isInTag) {
const whitespace = getWhiteSpace(htmlString, pos);
tokens.push({ type: "whitespace", value: whitespace, pos });
pos += whitespace.length;
isInAttribute = false;
continue;
}
if (char === ">" && isInTag && !isInAttribute) {
isInTag = false;
tokens.push({ type: "openTagEnd", value: ">", pos });
pos++;
continue;
}
if (isInTag && !isInAttribute && char === "/" && nextChar === ">") {
isInTag = false;
tokens.push({ type: "selfClose", value: "/>", pos });
pos += 2;
continue;
}
if (isInTag && !isInAttribute) {
isInAttribute = true;
const attributeName = getAttributeName(htmlString, pos);
tokens.push({ type: "attributeName", value: attributeName, pos });
pos += attributeName.length;
if (htmlString.charAt(pos) !== "=" && htmlString.charAt(pos) !== "'" && htmlString.charAt(pos) !== '"') {
isInAttribute = false;
}
continue;
}
if (char === "=" && isInAttribute && isInTag) {
isAfterAttributeEqual = true;
tokens.push({ type: "equal", value: "=", pos });
pos++;
continue;
}
if (isAfterAttributeEqual && isInAttribute && isInTag) {
const hasQuote = char === "'" || char === '"';
const quote = hasQuote ? char : "";
if (hasQuote) {
tokens.push({ type: "quoteStart", value: quote, pos });
pos++;
}
const attributeValue = getAttributeValue(htmlString, pos, quote);
tokens.push({ type: "attributeValue", value: attributeValue, pos });
pos += attributeValue.length;
if (hasQuote && htmlString.charAt(pos) === quote) {
tokens.push({ type: "quoteEnd", value: quote, pos });
pos++;
}
isInAttribute = false;
isAfterAttributeEqual = false;
continue;
}
if (!isInTag && char === "<" && nextChar === "/") {
tokens.push({ type: "closeTagStart", value: "</", pos });
pos += 2;
const tagName = getCloseTagName(htmlString, pos);
tokens.push({ type: "tagName", value: tagName, pos });
pos += tagName.length;
if (htmlString.charAt(pos) === ">") {
tokens.push({ type: "closeTagEnd", value: ">", pos });
pos++;
}
continue;
}
if (!isInTag) {
const text = getText(htmlString, pos);
tokens.push({ type: "text", value: text, pos });
pos += text.length;
continue;
}
}
return tokens;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,87 @@
import { describe, expect } from "vitest";
import { createEditor } from "./context";
import { render } from "@solidjs/testing-library";
import { it } from "~/test-helpers";
import { createSignal } from "solid-js";
describe('createEditor', () => {
describe('create', () => {
it('should create', async () => {
// Arrange
const res = render(() => <div data-testid="editor" />);
const ref = await res.findByTestId('editor');
// Act
const actual = createEditor(
() => ref,
() => '<p>this is a string</p>'
);
// Assert
expect(actual).toBeTruthy();
});
it('should update after a change has taken place', async () => {
// Arrange
const [value, setValue] = createSignal('<p>this is a string</p>');
const res = render(() => {
const [ref, setRef] = createSignal<Element>();
const [text] = createEditor(ref, value);
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
});
const ref = await res.findByTestId('editor');
// Act
setValue('<p>this is another totally different string</p>');
// Assert
expect(ref.innerHTML).toBe('<p>this is another totally different string</p>');
});
});
describe('selection', () => {
it('should not fail if there are no selection ranges', async () => {
// Arrange
const res = render(() => {
const [ref, setRef] = createSignal<Element>();
const [text] = createEditor(ref, () => '<p>paragraph 1</p>\n<p>paragraph 2</p>\n<p>paragraph 3</p>');
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
});
const ref = await res.findByTestId('editor');
// Act
window.getSelection()!.removeAllRanges();
// Assert
expect(true).toBeTruthy();
});
it('should react to changes in selection', async () => {
// Arrange
const res = render(() => {
const [ref, setRef] = createSignal<Element>();
const [text] = createEditor(ref, () => '<p>paragraph 1</p>\n<p>paragraph 2</p>\n<p>paragraph 3</p>');
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
});
const ref = await res.findByTestId('editor');
// Act
ref.focus();
window.getSelection()!.setBaseAndExtent(ref.childNodes[0].childNodes[0], 0, ref.childNodes[0].childNodes[0], 10);
console.log(window.getSelection()!.rangeCount);
// Assert
expect(true).toBeTruthy();
});
});
});

View file

@ -1,18 +1,17 @@
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener";
import { Accessor, createEffect, createMemo, onMount } from "solid-js";
import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js";
import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web";
import { createSelection, getTextNodes } from "@solid-primitives/selection";
import { visit } from "unist-util-visit";
import type { Root, Text } from 'hast';
import { unified } from "unified";
import { createMap } from './map';
import { splice } from "~/utilities";
import rehypeParse from "rehype-parse";
type EditContext = [Accessor<string>];
type Editor = [Accessor<string>];
export function createEditContext(ref: Accessor<HTMLElement | undefined>, value: Accessor<string>): EditContext {
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
if (isServer) {
return [createMemo(() => value())];
return [value];
}
if (!("EditContext" in window)) {
@ -34,51 +33,15 @@ export function createEditContext(ref: Accessor<HTMLElement | undefined>, value:
});
const ast = createMemo(() => unified().use(rehypeParse).parse(store.text));
const indices = createMemo(() => {
const root = ref();
if (!root) {
return [];
}
const nodes = getTextNodes(root);
const indices: { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }[] = [];
let index = 0;
visit(ast(), n => n.type === 'text', (node) => {
const { position, value } = node as Text;
const end = index + value.length;
if (position) {
indices.push({ node: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } });
}
index = end;
});
return indices;
});
const [selection, setSelection] = createSelection();
createEffect(() => {
console.log(indices());
});
const indexMap = createMap(() => ref()!, ast);
createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) {
const { updateRangeStart: start, updateRangeEnd: end } = e;
const { updateRangeStart: start, updateRangeEnd: end, text } = e;
setStore('text', `${store.text.slice(0, start)}${e.text}${store.text.slice(end)}`);
setStore('text', `${store.text.slice(0, start)}${text}${store.text.slice(end)}`);
updateSelection(toRange(ref()!, start, end));
setTimeout(() => {
console.log('hmmm', e, start, end);
context.updateSelection(start, end);
setSelection([ref()!, start, end]);
}, 1000);
context.updateSelection(start + text.length, start + text.length);
},
compositionstart() {
@ -102,37 +65,43 @@ export function createEditContext(ref: Accessor<HTMLElement | undefined>, value:
},
});
function updateText(start: number, end: number, text: string) {
context.updateText(start, end, text);
setStore('text', splice(store.text, start, end, text));
context.updateSelection(start + text.length, start + text.length);
}
function updateControlBounds() {
context.updateControlBounds(ref()!.getBoundingClientRect());
}
function updateSelection(range: Range) {
const [start, end] = toIndices(ref()!, range);
let index = 0;
let mappedStart = -1;
let mappedEnd = -1;
visit(ast(), n => n.type === 'text', (node) => {
const { position, value } = node as Text;
if (position) {
if (index <= start && (index + value.length) >= start) {
mappedStart = position.start.offset! + range.startOffset;
}
if (index <= end && (index + value.length) >= end) {
mappedEnd = position.start.offset! + range.endOffset;
}
}
index += value.length;
});
context.updateSelection(mappedStart, mappedEnd);
context.updateSelection(...indexMap.toHtmlIndices(range));
context.updateSelectionBounds(range.getBoundingClientRect());
setSelection([ref()!, start, end]);
queueMicrotask(() => {
const selection = window.getSelection();
if (selection === null) {
return;
}
if (selection.rangeCount !== 0) {
const existingRange = selection.getRangeAt(0);
if (equals(range, existingRange)) {
return;
}
selection.removeAllRanges();
}
console.log('is it me?');
selection.addRange(range);
});
}
WindowEventListener({
@ -149,27 +118,28 @@ export function createEditContext(ref: Accessor<HTMLElement | undefined>, value:
return;
}
const start = context.selectionStart;
const end = context.selectionEnd;
const start = Math.min(context.selectionStart, context.selectionEnd);
let end = Math.max(context.selectionStart, context.selectionEnd);
if (e.key === 'Tab') {
e.preventDefault();
context.updateText(start, end, '\t');
// updateSelection(start + 1, start + 1);
updateText(start, end, '&nbsp;&nbsp;&nbsp;&nbsp;');
} else if (e.key === 'Enter') {
context.updateText(start, end, '\n');
// updateSelection(start + 1, start + 1);
updateText(start, end, '\n');
}
},
});
DocumentEventListener({
onSelectionchange(e) {
const selection = document.getSelection()!;
const selection = document.getSelection();
if (selection.rangeCount < 1) {
if (selection === null) {
return;
}
if (selection.rangeCount === 0) {
return;
}
@ -185,7 +155,7 @@ export function createEditContext(ref: Accessor<HTMLElement | undefined>, value:
updateControlBounds();
});
createEffect((last?: HTMLElement) => {
createEffect((last?: Element) => {
if (last !== undefined) {
last.editContext = undefined;
}
@ -202,14 +172,31 @@ export function createEditContext(ref: Accessor<HTMLElement | undefined>, value:
});
createEffect(() => {
context.updateText(0, 0, value());
updateText(0, -0, value());
});
createEffect(() => {
store.text;
if (document.activeElement === untrack(ref)) {
queueMicrotask(() => {
console.log();
updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd));
});
}
});
return [createMemo(() => store.text)];
}
const equals = (a: Range, b: Range): boolean => {
const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer'];
return keys.every(key => a[key] === b[key]);
}
declare global {
interface HTMLElement {
interface Element {
editContext?: EditContext;
}
@ -275,50 +262,4 @@ declare global {
}
var EditContext: EditContextConstructor;
}
const offsetOf = (node: Node, nodes: Node[]) => nodes.slice(0, nodes.indexOf(node)).reduce((t, n) => t + n.textContent!.length, 0);
const toRange = (root: Node, start: number, end: number): Range => {
let index = 0;
let startNode = null;
let endNode = null;
for (const node of getTextNodes(root)) {
const length = node.textContent!.length;
if (index <= start && (index + length) >= start) {
startNode = [node, Math.abs(end - index)] as const;
}
if (index <= end && (index + length) >= end) {
endNode = [node, Math.abs(end - index)] as const;
}
if (startNode !== null && endNode !== null) {
break;
}
index += length;
}
const range = new Range();
if (startNode !== null) {
range.setStart(...startNode);
}
if (endNode !== null) {
range.setEnd(...endNode);
}
return range;
};
const toIndices = (node: Node, range: Range): [number, number] => {
const nodes = getTextNodes(node);
const start = offsetOf(range.startContainer, nodes) + range.startOffset;
const end = offsetOf(range.endContainer, nodes) + range.endOffset;
return [start, end];
};
}

View file

@ -0,0 +1,20 @@
import { createContextProvider } from "@solid-primitives/context";
import { createEffect, ParentProps } from "solid-js";
import { createEditor } from "./context";
const [EditorProvider, useEditor] = createContextProvider((props: { ref: Element, value: string }) => {
const [text] = createEditor(() => props.ref, () => props.value);
createEffect(() => {
console.log(text());
});
return { text };
});
export { useEditor };
export function Editor(props: ParentProps<{ ref: Element, value: string }>) {
return <EditorProvider ref={props.ref} value={props.value}>{props.children}</EditorProvider>;
}

View file

@ -0,0 +1,4 @@
export { createEditor as createEditContext } from './context';
export { Editor, useEditor } from './editor';

114
src/features/editor/map.ts Normal file
View file

@ -0,0 +1,114 @@
import type { Root, Text } from 'hast';
import { getTextNodes } from '@solid-primitives/selection';
import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { visit } from 'unist-util-visit';
type IndexNode = { node: Node, text: { start: number, end: number }, html: { start: number, end: number } };
type IndexMap = IndexNode[];
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(() => {
latestMutations();
const node = root();
if (node === undefined) {
return [];
}
return createIndices(node, ast());
});
return {
atHtmlPosition(index: number) {
return indices().find(({ html }) => html.start <= index && html.end >= index);
},
toTextIndices(range: Range): [number, number] {
const startNode = indices().find(({ node }) => node === range.startContainer);
const endNode = indices().find(({ node }) => node === range.endContainer);
return [
startNode ? (startNode.text.start + range.startOffset) : -1,
endNode ? (endNode.text.start + range.endOffset) : -1
];
},
toHtmlIndices(range: Range): [number, number] {
const startNode = indices().find(({ node }) => node === range.startContainer);
const endNode = indices().find(({ node }) => node === range.endContainer);
return [
startNode ? (startNode.html.start + range.startOffset) : -1,
endNode ? (endNode.html.start + range.endOffset) : -1
];
},
toRange(start: number, end: number): Range {
const startNode = indices().find(({ html }) => html.start <= start && html.end >= start);
const endNode = indices().find(({ html }) => html.start <= end && html.end >= end);
const range = new Range();
if (startNode) {
const offset = start - startNode.html.start;
range.setStart(startNode.node, offset);
}
if (endNode) {
const offset = end - endNode.html.start;
range.setEnd(endNode.node, offset);
}
return range;
},
};
}
const createIndices = (root: Node, ast: Root): IndexMap => {
const nodes = getTextNodes(root);
const indices: IndexMap = [];
let index = 0;
visit(ast, n => n.type === 'text', (node) => {
const { position, value } = node as Text;
const end = index + value.length;
if (position) {
indices.push({ node: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } });
}
index = end;
});
return indices;
};
const observe = (node: Accessor<Node | undefined>): Accessor<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 mutations;
};

View file

@ -10,7 +10,7 @@ describe('Source', () => {
// Arrange
// Act
const actual = createSource('');
const actual = createSource(() => '');
// Assert
expect(actual.out).toBe('');
@ -22,7 +22,7 @@ describe('Source', () => {
const expected = '<p><strong>text</strong></p>';
// Act
const actual = createSource(given);
const actual = createSource(() => given);
// Assert
expect(actual.out).toBe(expected);
@ -31,7 +31,7 @@ describe('Source', () => {
it('should contain query results', () => {
// Arrange
const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]];
const source = createSource('this is a seachable string');
const source = createSource(() => 'this is a seachable string');
// Act
source.query = 'a';

View file

@ -2,6 +2,7 @@ import { createSignal } from "solid-js";
import { debounce } from "@solid-primitives/scheduled";
import { Textarea } from "~/components/textarea";
import css from './formatter.module.css';
import { Editor } from "~/features/editor";
const tempVal = `
# Header
@ -37,7 +38,7 @@ export default function Formatter(props: {}) {
}, 300);
return <div class={css.root}>
<textarea oninput={onInput}>{value()}</textarea>
<Textarea class={css.textarea} value={value()} oninput={setValue} lang="en-GB" />
<textarea oninput={onInput} title="markdown">{value()}</textarea>
<Textarea class={css.textarea} title="html" value={value()} oninput={setValue} lang="en-GB" />
</div>;
}

View file

@ -1,5 +1,5 @@
import { describe, expect, vi } from 'vitest';
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, split_by_filter, splitAt } from './utilities';
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, splice, split_by_filter, splitAt } from './utilities';
import { it } from '~/test-helpers';
const { spyOn } = vi;
@ -11,6 +11,44 @@ const first = <T>(iterable: Iterable<T>): T | undefined => {
}
describe('utilities', () => {
describe('splice', () => {
it('can replace part of string based on indices', async () => {
// Arrange
const given = 'this is a string';
const expected = 'this was a string';
// Act
const actual = splice(given, 5, 7, 'was');
// Assert
expect(actual).toBe(expected);
});
it('can replace from the start', async () => {
// Arrange
const given = 'this is a string';
const expected = 'was a string';
// Act
const actual = splice(given, 0, 7, 'was');
// Assert
expect(actual).toBe(expected);
});
it('can replace till the end', async () => {
// Arrange
const given = 'this is a string';
const expected = 'this was';
// Act
const actual = splice(given, 5, -0, 'was');
// Assert
expect(actual).toBe(expected);
});
});
describe('splitAt', () => {
it('should split the given string at the given index', async () => {
// Arrange

View file

@ -1,3 +1,6 @@
export const splice = (subject: string, start: number, end: number, replacement: string) => {
return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`;
};
export const splitAt = (subject: string, index: number): readonly [string, string] => {
if (index < 0) {
return [subject, ''];