got to a stable point again. next up is comming up with a decent API for modifications
This commit is contained in:
parent
4fb7405466
commit
fc22ce6027
19 changed files with 498 additions and 375 deletions
|
@ -1,3 +0,0 @@
|
|||
|
||||
|
||||
export { createEditContext } from './context';
|
|
@ -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 |
87
src/features/editor/context.spec.tsx
Normal file
87
src/features/editor/context.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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, ' ');
|
||||
} 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];
|
||||
};
|
||||
}
|
20
src/features/editor/editor.tsx
Normal file
20
src/features/editor/editor.tsx
Normal 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>;
|
||||
}
|
4
src/features/editor/index.tsx
Normal file
4
src/features/editor/index.tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
|
||||
export { createEditor as createEditContext } from './context';
|
||||
export { Editor, useEditor } from './editor';
|
114
src/features/editor/map.ts
Normal file
114
src/features/editor/map.ts
Normal 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;
|
||||
};
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue