made a start on mutating the AST
This commit is contained in:
parent
603719de38
commit
97036272dd
12 changed files with 318 additions and 165 deletions
4
bun.lock
4
bun.lock
|
@ -32,8 +32,10 @@
|
|||
"solid-js": "^1.9.5",
|
||||
"ts-pattern": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-ancestor": "^1.4.3",
|
||||
"unist-util-find": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"vinxi": "^0.5.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1471,6 +1473,8 @@
|
|||
|
||||
"unimport": ["unimport@3.14.6", "", { "dependencies": { "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "fast-glob": "^3.3.3", "local-pkg": "^1.0.0", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.1", "picomatch": "^4.0.2", "pkg-types": "^1.3.0", "scule": "^1.3.0", "strip-literal": "^2.1.1", "unplugin": "^1.16.1" } }, "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g=="],
|
||||
|
||||
"unist-util-ancestor": ["unist-util-ancestor@1.4.3", "", { "dependencies": { "unist-util-visit-parents": "^6.0.1" } }, "sha512-UUllGrozJ4w/zms9+sUMqmmHTEiCUnvoXu8AkEtrrUhfD9RCwUzEjubObNFpLasm+jW/JFFn3kZvVRS4xAtvtg=="],
|
||||
|
||||
"unist-util-find": ["unist-util-find@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "lodash.iteratee": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-T7ZqS7immLjYyC4FCp2hDo3ksZ1v+qcbb+e5+iWxc2jONgHOLXPCpms1L8VV4hVxCXgWTxmBHDztuEZFVwC+Gg=="],
|
||||
|
||||
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
|
||||
|
|
|
@ -34,8 +34,10 @@
|
|||
"solid-js": "^1.9.5",
|
||||
"ts-pattern": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-ancestor": "^1.4.3",
|
||||
"unist-util-find": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"vinxi": "^0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { Component, createEffect, createMemo, createSignal, For, on, onMount, untrack } from 'solid-js';
|
||||
import { createEffect, createSignal, on, onMount } from 'solid-js';
|
||||
import { debounce } from '@solid-primitives/scheduled';
|
||||
import { createSelection, getTextNodes } from '@solid-primitives/selection';
|
||||
import { createSource } from '~/features/source';
|
||||
import css from './textarea.module.css';
|
||||
import { debounce } from '@solid-primitives/scheduled';
|
||||
|
||||
interface TextareaProps {
|
||||
class?: string;
|
||||
|
@ -21,7 +20,7 @@ export function Textarea(props: TextareaProps) {
|
|||
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
|
||||
let mounted = false;
|
||||
|
||||
const source = createSource(props.value);
|
||||
const source = createSource(() => props.value);
|
||||
|
||||
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
|
||||
if (!mounted) {
|
||||
|
@ -44,6 +43,8 @@ export function Textarea(props: TextareaProps) {
|
|||
const ref = editorRef();
|
||||
|
||||
if (ref) {
|
||||
console.log(ref.innerHTML);
|
||||
|
||||
source.out = ref.innerHTML;
|
||||
|
||||
ref.style.height = `1px`;
|
||||
|
@ -77,116 +78,17 @@ export function Textarea(props: TextareaProps) {
|
|||
createHighlights(ref, 'search-results', errors);
|
||||
}));
|
||||
|
||||
return <>
|
||||
<Suggestions />
|
||||
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
|
||||
<div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
lang={props.lang}
|
||||
innerHTML={source.out}
|
||||
data-placeholder={props.placeholder ?? ''}
|
||||
on:keydown={e => e.stopPropagation()}
|
||||
on:pointerdown={e => e.stopPropagation()}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
const Suggestions: Component = () => {
|
||||
const [selection] = createSelection();
|
||||
const [suggestionRef, setSuggestionRef] = createSignal<HTMLElement>();
|
||||
const [suggestions, setSuggestions] = createSignal<string[]>([]);
|
||||
|
||||
const marker = createMemo(() => {
|
||||
if (isServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [n] = selection();
|
||||
const s = window.getSelection();
|
||||
|
||||
if (n === null || s === null || s.rangeCount < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined;
|
||||
});
|
||||
|
||||
createEffect<HTMLElement | undefined>((prev) => {
|
||||
if (prev) {
|
||||
prev.style.setProperty('anchor-name', null);
|
||||
}
|
||||
|
||||
const m = marker();
|
||||
const ref = untrack(() => suggestionRef()!);
|
||||
|
||||
if (m === undefined) {
|
||||
if (ref.matches(':popover-open')) {
|
||||
ref.hidePopover();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
m.style.setProperty('anchor-name', '--suggestions');
|
||||
|
||||
if (ref.matches(':not(:popover-open)')) {
|
||||
ref.showPopover();
|
||||
}
|
||||
|
||||
ref.focus()
|
||||
|
||||
return m;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
marker();
|
||||
|
||||
setSuggestions(Array(Math.ceil(Math.random() * 5)).fill('').map((_, i) => `suggestion ${i}`));
|
||||
});
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
marker()?.replaceWith(document.createTextNode(e.target.textContent));
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
return <menu ref={setSuggestionRef} class={css.suggestions} popover="manual" onkeydown={onKeyDown}>
|
||||
<For each={suggestions()}>{
|
||||
suggestion => <li onpointerdown={onPointerDown}>{suggestion}</li>
|
||||
}</For>
|
||||
</menu>;
|
||||
};
|
||||
|
||||
const findMarkerNode = (node: Node | null) => {
|
||||
while (node !== null) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).hasAttribute('data-marker')) {
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const spellChecker = checker(/\w+/gi);
|
||||
const grammarChecker = checker(/\w+\s+\w+/gi);
|
||||
|
||||
function checker(regex: RegExp) {
|
||||
return (subject: string, lang: string): [number, number][] => {
|
||||
// return [];
|
||||
|
||||
const threshold = .75//.99;
|
||||
|
||||
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
|
||||
return [index, index + match.length] as const;
|
||||
});
|
||||
}
|
||||
return <div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
lang={props.lang}
|
||||
innerHTML={source.out}
|
||||
data-placeholder={props.placeholder ?? ''}
|
||||
on:keydown={e => e.stopPropagation()}
|
||||
on:pointerdown={e => e.stopPropagation()}
|
||||
/>;
|
||||
}
|
||||
|
||||
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
|
||||
|
|
0
src/features/editor/ast.spec.ts
Normal file
0
src/features/editor/ast.spec.ts
Normal file
93
src/features/editor/ast.ts
Normal file
93
src/features/editor/ast.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import type { Node, Text, Element, ElementContent, Parent, RootContent } from 'hast';
|
||||
import { find } from 'unist-util-find';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { deepCopy } from '~/utilities';
|
||||
|
||||
/**
|
||||
*
|
||||
* Given
|
||||
* root
|
||||
* |- element
|
||||
* | |- text [0, 6]
|
||||
* | |- element
|
||||
* | | |- text [7, 18]
|
||||
* | |- text [19, 25]
|
||||
* |- element
|
||||
* |- text [26, 40]
|
||||
* |- element
|
||||
* | |- text [41, 53]
|
||||
* |- text [54, 60]
|
||||
*
|
||||
* split at 10
|
||||
*
|
||||
* root
|
||||
* |- element
|
||||
* | |- text [0, 6]
|
||||
* | |- element
|
||||
* | | |- text [7, 9]
|
||||
*
|
||||
* root
|
||||
* |- element
|
||||
* | |- element
|
||||
* | | |- text [10, 18]
|
||||
* | |- text [19, 25]
|
||||
* |- element
|
||||
* |- text [26, 40]
|
||||
* |- element
|
||||
* | |- text [41, 53]
|
||||
* |- text [54, 60]
|
||||
*/
|
||||
|
||||
export const splitAt = (tree: Parent, node: Text, offset: number): [RootContent[], RootContent[]] => {
|
||||
const index = tree.children.findIndex(c => find(c, { ...node }));
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('The tree does not contain the given node');
|
||||
}
|
||||
|
||||
const left = tree.children.slice(0, index);
|
||||
const right = tree.children.slice(index + 1);
|
||||
|
||||
if (offset === 0) {
|
||||
right.unshift(tree.children[index]);
|
||||
}
|
||||
else if (offset === node.value.length) {
|
||||
left.push(tree.children[index]);
|
||||
}
|
||||
else {
|
||||
const targetLeft = deepCopy(tree.children[index]);
|
||||
const targetRight = tree.children[index];
|
||||
|
||||
left.push(targetLeft);
|
||||
right.unshift(targetRight);
|
||||
|
||||
visit(targetLeft, (n): n is Text => equals(n, node), n => {
|
||||
n.value = n.value.slice(0, offset);
|
||||
})
|
||||
|
||||
visit(targetRight, (n): n is Text => equals(n, node), n => {
|
||||
n.value = n.value.slice(offset);
|
||||
})
|
||||
}
|
||||
|
||||
return [left, right];
|
||||
};
|
||||
|
||||
const splitNode = (node: Node, offset: number) => {
|
||||
|
||||
}
|
||||
|
||||
const equals = (a: Node, b: Node): boolean => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is the nasty version of deep object checking,
|
||||
// but I hope this is safe to do in this case because
|
||||
// we are working with a html-ast and not just any type of object.
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
};
|
|
@ -2,15 +2,16 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } fr
|
|||
import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { isServer } from "solid-js/web";
|
||||
import { unified } from "unified";
|
||||
import { createMap } from './map';
|
||||
import { createMap, IndexRange } from './map';
|
||||
import { splice } from "~/utilities";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import { createState } from "./state";
|
||||
import type { Root } from 'hast';
|
||||
|
||||
type Editor = [Accessor<string>, { select(range: Range): void, mutate(setter: (text: string) => string): void, readonly selection: Accessor<Range | undefined> }];
|
||||
export type SelectFunction = (range: Range) => void;
|
||||
export type MutateFunction = (setter: (ast: Root) => Root) => void;
|
||||
type Editor = [Accessor<string>, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor<IndexRange> }];
|
||||
|
||||
interface EditorStoreType {
|
||||
text: string;
|
||||
isComposing: boolean;
|
||||
selection: Range | undefined;
|
||||
characterBounds: DOMRect[];
|
||||
|
@ -23,7 +24,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
return [value, {
|
||||
select() { },
|
||||
mutate() { },
|
||||
selection: () => undefined,
|
||||
selection: () => [undefined, undefined],
|
||||
}];
|
||||
}
|
||||
|
||||
|
@ -35,8 +36,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
text: value(),
|
||||
});
|
||||
|
||||
const state = createState(value);
|
||||
const indexMap = createMap(() => ref()!, () => state.ast);
|
||||
const [store, setStore] = createStore<EditorStoreType>({
|
||||
text: value(),
|
||||
isComposing: false,
|
||||
selection: undefined,
|
||||
|
||||
|
@ -46,9 +48,6 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
selectionBounds: new DOMRect(),
|
||||
});
|
||||
|
||||
const ast = createMemo(() => unified().use(rehypeParse).parse(store.text));
|
||||
const indexMap = createMap(() => ref()!, ast);
|
||||
|
||||
createEventListenerMap<any>(context, {
|
||||
textupdate(e: TextUpdateEvent) {
|
||||
const { updateRangeStart: start, updateRangeEnd: end, text } = e;
|
||||
|
@ -82,7 +81,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
function updateText(start: number, end: number, text: string) {
|
||||
context.updateText(start, end, text);
|
||||
|
||||
setStore('text', splice(store.text, start, end, text));
|
||||
state.text = splice(state.text, start, end, text);
|
||||
|
||||
context.updateSelection(start + text.length, start + text.length);
|
||||
}
|
||||
|
@ -167,6 +166,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
|
||||
onMount(() => {
|
||||
updateControlBounds();
|
||||
|
||||
updateSelection(indexMap.toRange(40, 60))
|
||||
});
|
||||
|
||||
createEffect((last?: Element) => {
|
||||
|
@ -185,34 +186,43 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
|||
return el;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
updateText(0, -0, value());
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
store.text;
|
||||
state.text;
|
||||
|
||||
if (document.activeElement === untrack(ref)) {
|
||||
queueMicrotask(() => {
|
||||
console.log();
|
||||
|
||||
updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
createMemo(() => store.text),
|
||||
createMemo(() => state.text),
|
||||
{
|
||||
select(range: Range) {
|
||||
updateSelection(range);
|
||||
},
|
||||
|
||||
mutate(setter) {
|
||||
setStore('text', setter);
|
||||
state.ast = setter(state.ast);
|
||||
},
|
||||
|
||||
selection: createMemo(() => store.selection),
|
||||
selection: createMemo<IndexRange>(() => {
|
||||
const selection = store.selection;
|
||||
|
||||
if (!selection) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
return indexMap.atHtmlPosition(selection);
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { createContextProvider } from "@solid-primitives/context";
|
||||
import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js";
|
||||
import { createEditor } from "./context";
|
||||
import { createEditor, MutateFunction, SelectFunction } from "./context";
|
||||
import { createSource, Source } from "../source";
|
||||
import { getTextNodes } from "@solid-primitives/selection";
|
||||
import { isServer } from "solid-js/web";
|
||||
import { IndexRange } from "./map";
|
||||
|
||||
interface EditorContextType {
|
||||
readonly text: Accessor<string>;
|
||||
readonly selection: Accessor<Range | undefined>;
|
||||
readonly selection: Accessor<IndexRange>;
|
||||
readonly source: Source;
|
||||
select(range: Range): void;
|
||||
mutate(setter: (prev: string) => string): void;
|
||||
select: SelectFunction;
|
||||
mutate: MutateFunction;
|
||||
}
|
||||
|
||||
interface EditorContextProps extends Record<string, unknown> {
|
||||
|
@ -52,7 +53,7 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
|
|||
};
|
||||
}, {
|
||||
text: () => '',
|
||||
selection: () => undefined,
|
||||
selection: () => [undefined, undefined],
|
||||
source: {} as Source,
|
||||
select() { },
|
||||
mutate() { },
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
|
||||
export { createEditor as createEditContext } from './context';
|
||||
export { Editor, useEditor } from './editor';
|
||||
export { Editor, useEditor } from './editor';
|
||||
export { splitAt } from './ast';
|
|
@ -3,17 +3,16 @@ 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 type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number }, offset: number };
|
||||
export type IndexMap = IndexNode[];
|
||||
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
|
||||
|
||||
export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) {
|
||||
// Observe the element so that the references to the nodes in the indices are updated if the DOM is changed
|
||||
const latestMutations = observe(root);
|
||||
|
||||
const indices = createMemo(() => {
|
||||
latestMutations();
|
||||
|
||||
const node = root();
|
||||
const [node] = latestMutations();
|
||||
|
||||
if (node === undefined) {
|
||||
return [];
|
||||
|
@ -23,13 +22,36 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
|||
});
|
||||
|
||||
return {
|
||||
atHtmlPosition(index: number) {
|
||||
return indices().find(({ html }) => html.start <= index && html.end >= index);
|
||||
atHtmlPosition(range: Range): IndexRange {
|
||||
const start = { ...(indices().find(({ dom }) => dom === range.startContainer)!) };
|
||||
const end = indices().find(({ dom }) => dom === range.endContainer);
|
||||
|
||||
if (!start || !end) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
start.offset = range.startOffset;
|
||||
end.offset = range.endOffset;
|
||||
|
||||
return [start, end];
|
||||
},
|
||||
|
||||
atTextPosition(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];
|
||||
}
|
||||
|
||||
startNode.offset = start - startNode.html.start;
|
||||
endNode.offset = end - endNode.html.start;
|
||||
|
||||
return [startNode, endNode];
|
||||
},
|
||||
|
||||
toTextIndices(range: Range): [number, number] {
|
||||
const startNode = indices().find(({ node }) => node === range.startContainer);
|
||||
const endNode = indices().find(({ node }) => node === range.endContainer);
|
||||
const [startNode, endNode] = this.atHtmlPosition(range);
|
||||
|
||||
return [
|
||||
startNode ? (startNode.text.start + range.startOffset) : -1,
|
||||
|
@ -38,8 +60,7 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
|||
},
|
||||
|
||||
toHtmlIndices(range: Range): [number, number] {
|
||||
const startNode = indices().find(({ node }) => node === range.startContainer);
|
||||
const endNode = indices().find(({ node }) => node === range.endContainer);
|
||||
const [startNode, endNode] = this.atHtmlPosition(range);
|
||||
|
||||
return [
|
||||
startNode ? (startNode.html.start + range.startOffset) : -1,
|
||||
|
@ -48,21 +69,15 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
|||
},
|
||||
|
||||
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 [startNode, endNode] = this.atTextPosition(start, end);
|
||||
const range = new Range();
|
||||
|
||||
if (startNode) {
|
||||
const offset = start - startNode.html.start;
|
||||
|
||||
range.setStart(startNode.node, offset);
|
||||
range.setStart(startNode.dom, startNode.offset);
|
||||
}
|
||||
|
||||
if (endNode) {
|
||||
const offset = end - endNode.html.start;
|
||||
|
||||
range.setEnd(endNode.node, offset);
|
||||
range.setEnd(endNode.dom, endNode.offset);
|
||||
}
|
||||
|
||||
return range;
|
||||
|
@ -75,12 +90,12 @@ const createIndices = (root: Node, ast: Root): IndexMap => {
|
|||
const indices: IndexMap = [];
|
||||
|
||||
let index = 0;
|
||||
visit(ast, n => n.type === 'text', (node) => {
|
||||
visit(ast, (n): n is Text => 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! } });
|
||||
indices.push({ node, dom: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! }, offset: 0 });
|
||||
}
|
||||
|
||||
index = end;
|
||||
|
@ -89,7 +104,7 @@ const createIndices = (root: Node, ast: Root): IndexMap => {
|
|||
return indices;
|
||||
};
|
||||
|
||||
const observe = (node: Accessor<Node | undefined>): Accessor<MutationRecord[]> => {
|
||||
const observe = (node: Accessor<Node | undefined>): Accessor<readonly [Node | undefined, MutationRecord[]]> => {
|
||||
const [mutations, setMutations] = createSignal<MutationRecord[]>([]);
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
|
@ -110,5 +125,5 @@ const observe = (node: Accessor<Node | undefined>): Accessor<MutationRecord[]> =
|
|||
observer.disconnect();
|
||||
});
|
||||
|
||||
return mutations;
|
||||
return createMemo(() => [node(), mutations()] as const);
|
||||
};
|
43
src/features/editor/state.ts
Normal file
43
src/features/editor/state.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
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) {
|
||||
console.log(stringify(next));
|
||||
|
||||
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);
|
|
@ -1,6 +1,9 @@
|
|||
import { createEffect, createMemo, createSignal, onMount } from "solid-js";
|
||||
import { createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
import { Editor, useEditor } from "~/features/editor";
|
||||
import { Editor, splitAt, useEditor } from "~/features/editor";
|
||||
import { visitParents } from "unist-util-visit-parents";
|
||||
import findAncestor from 'unist-util-ancestor';
|
||||
import type * as hast from 'hast';
|
||||
import css from './editor.module.css';
|
||||
|
||||
const tempVal = `
|
||||
|
@ -49,13 +52,91 @@ export default function Formatter(props: {}) {
|
|||
function Toolbar() {
|
||||
const { mutate, selection } = useEditor();
|
||||
|
||||
const matchesAncestor = (tree: hast.Node, node: hast.Text, predicate: (node: hast.Node) => boolean) => {
|
||||
let matches = false;
|
||||
|
||||
visitParents(tree, n => n === node, (_, ancestors) => {
|
||||
matches = ancestors.some(predicate);
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const bold = () => {
|
||||
console.log('toggle text bold', selection());
|
||||
const [start, end] = selection();
|
||||
|
||||
if (!start || !end) {
|
||||
return
|
||||
}
|
||||
|
||||
mutate((ast) => {
|
||||
console.log(end.node.value.slice(0, end.offset));
|
||||
|
||||
// Trim whitespace from selection
|
||||
const matchStart = start.node.value.slice(start.offset).match(/^(\s+).*?$/);
|
||||
if (matchStart !== null) {
|
||||
start.offset += matchStart[1].length;
|
||||
}
|
||||
|
||||
const matchEnd = end.node.value.slice(0, end.offset).match(/^.*?(\s+)$/);
|
||||
if (matchEnd !== null) {
|
||||
end.offset -= matchEnd[1].length;
|
||||
}
|
||||
|
||||
// Edge case Unbold the selected characters
|
||||
if (start.node === end.node) {
|
||||
visitParents(ast, (n): n is hast.Text => n === start.node, (n, ancestors) => {
|
||||
const [strong, parent] = ancestors.toReversed();
|
||||
|
||||
if (strong.type === 'element' && strong.tagName === 'strong') {
|
||||
parent.children.splice(parent.children.indexOf(strong as hast.ElementContent), 1,
|
||||
{ type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(0, start.offset) }] },
|
||||
{ type: 'text', value: n.value.slice(start.offset, end.offset) },
|
||||
{ type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(end.offset) }] },
|
||||
);
|
||||
}
|
||||
else {
|
||||
strong.children.splice(strong.children.indexOf(n), 1,
|
||||
{ type: 'text', value: n.value.slice(0, start.offset) },
|
||||
{ type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(start.offset, end.offset) }] },
|
||||
{ type: 'text', value: n.value.slice(end.offset) },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
const common = findAncestor(ast, [start.node, end.node] as const) as hast.Element;
|
||||
const startIsBold = matchesAncestor(common, start.node, (node) => node.type === 'element' && node.tagName === 'strong');
|
||||
const endIsBold = matchesAncestor(common, end.node, (node) => node.type === 'element' && node.tagName === 'strong');
|
||||
|
||||
// Extend to left
|
||||
if (startIsBold) {
|
||||
start.offset = 0;
|
||||
}
|
||||
|
||||
// Extend to right
|
||||
if (endIsBold) {
|
||||
end.offset = end.node.value.length;
|
||||
}
|
||||
|
||||
const [a, b] = splitAt(common, start.node, start.offset);
|
||||
const [c, d] = splitAt({ type: 'root', children: b }, end.node, end.offset);
|
||||
const boldedElement = { type: 'element', tagName: 'strong', children: c } as hast.RootContent;
|
||||
|
||||
common.children = [...a, boldedElement, ...d] as hast.ElementContent[];
|
||||
|
||||
console.log(c, d, common.children);
|
||||
|
||||
return ast;
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
return <div class={css.toolbar}>
|
||||
<button onclick={bold}>bold</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SearchAndReplace() {
|
||||
|
|
|
@ -129,7 +129,8 @@ export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, pa
|
|||
}
|
||||
};
|
||||
|
||||
const isIterable = (subject: object): subject is Iterable<any> => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false;
|
||||
const nonIterableTypes = ['boolean', 'undefined', 'null', 'number'];
|
||||
const isIterable = (subject: object): subject is Iterable<any> => nonIterableTypes.includes(typeof subject) === false;
|
||||
const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
|
||||
if (subject instanceof Array) {
|
||||
return subject.entries();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue