made a start on mutating the AST

This commit is contained in:
Chris Kruining 2025-03-10 16:48:37 +01:00
parent 603719de38
commit 97036272dd
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
12 changed files with 318 additions and 165 deletions

View file

@ -32,8 +32,10 @@
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"ts-pattern": "^5.6.2", "ts-pattern": "^5.6.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-ancestor": "^1.4.3",
"unist-util-find": "^3.0.0", "unist-util-find": "^3.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"unist-util-visit-parents": "^6.0.1",
"vinxi": "^0.5.3", "vinxi": "^0.5.3",
}, },
"devDependencies": { "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=="], "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": ["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=="], "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=="],

View file

@ -34,8 +34,10 @@
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"ts-pattern": "^5.6.2", "ts-pattern": "^5.6.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-ancestor": "^1.4.3",
"unist-util-find": "^3.0.0", "unist-util-find": "^3.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"unist-util-visit-parents": "^6.0.1",
"vinxi": "^0.5.3" "vinxi": "^0.5.3"
}, },
"devDependencies": { "devDependencies": {

View file

@ -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 { debounce } from '@solid-primitives/scheduled';
import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { createSelection, getTextNodes } from '@solid-primitives/selection';
import { createSource } from '~/features/source'; import { createSource } from '~/features/source';
import css from './textarea.module.css'; import css from './textarea.module.css';
import { debounce } from '@solid-primitives/scheduled';
interface TextareaProps { interface TextareaProps {
class?: string; class?: string;
@ -21,7 +20,7 @@ export function Textarea(props: TextareaProps) {
const [editorRef, setEditorRef] = createSignal<HTMLElement>(); const [editorRef, setEditorRef] = createSignal<HTMLElement>();
let mounted = false; let mounted = false;
const source = createSource(props.value); const source = createSource(() => props.value);
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => { createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
if (!mounted) { if (!mounted) {
@ -44,6 +43,8 @@ export function Textarea(props: TextareaProps) {
const ref = editorRef(); const ref = editorRef();
if (ref) { if (ref) {
console.log(ref.innerHTML);
source.out = ref.innerHTML; source.out = ref.innerHTML;
ref.style.height = `1px`; ref.style.height = `1px`;
@ -77,116 +78,17 @@ export function Textarea(props: TextareaProps) {
createHighlights(ref, 'search-results', errors); createHighlights(ref, 'search-results', errors);
})); }));
return <> return <div
<Suggestions /> ref={setEditorRef}
<input class={css.search} type="search" oninput={e => source.query = e.target.value} /> class={`${css.textarea} ${props.class}`}
<div contentEditable
ref={setEditorRef} dir="auto"
class={`${css.textarea} ${props.class}`} lang={props.lang}
contentEditable innerHTML={source.out}
dir="auto" data-placeholder={props.placeholder ?? ''}
lang={props.lang} on:keydown={e => e.stopPropagation()}
innerHTML={source.out} on:pointerdown={e => e.stopPropagation()}
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;
});
}
} }
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {

View file

View 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);
};

View file

@ -2,15 +2,16 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } fr
import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { unified } from "unified"; import { createMap, IndexRange } from './map';
import { createMap } from './map';
import { splice } from "~/utilities"; 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 { interface EditorStoreType {
text: string;
isComposing: boolean; isComposing: boolean;
selection: Range | undefined; selection: Range | undefined;
characterBounds: DOMRect[]; characterBounds: DOMRect[];
@ -23,7 +24,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
return [value, { return [value, {
select() { }, select() { },
mutate() { }, mutate() { },
selection: () => undefined, selection: () => [undefined, undefined],
}]; }];
} }
@ -35,8 +36,9 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
text: value(), text: value(),
}); });
const state = createState(value);
const indexMap = createMap(() => ref()!, () => state.ast);
const [store, setStore] = createStore<EditorStoreType>({ const [store, setStore] = createStore<EditorStoreType>({
text: value(),
isComposing: false, isComposing: false,
selection: undefined, selection: undefined,
@ -46,9 +48,6 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
selectionBounds: new DOMRect(), selectionBounds: new DOMRect(),
}); });
const ast = createMemo(() => unified().use(rehypeParse).parse(store.text));
const indexMap = createMap(() => ref()!, ast);
createEventListenerMap<any>(context, { createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) { textupdate(e: TextUpdateEvent) {
const { updateRangeStart: start, updateRangeEnd: end, text } = e; 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) { function updateText(start: number, end: number, text: string) {
context.updateText(start, end, text); 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); context.updateSelection(start + text.length, start + text.length);
} }
@ -167,6 +166,8 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
onMount(() => { onMount(() => {
updateControlBounds(); updateControlBounds();
updateSelection(indexMap.toRange(40, 60))
}); });
createEffect((last?: Element) => { createEffect((last?: Element) => {
@ -185,34 +186,43 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
return el; return el;
}); });
createEffect(() => {
});
createEffect(() => { createEffect(() => {
updateText(0, -0, value()); updateText(0, -0, value());
}); });
createEffect(() => { createEffect(() => {
store.text; state.text;
if (document.activeElement === untrack(ref)) { if (document.activeElement === untrack(ref)) {
queueMicrotask(() => { queueMicrotask(() => {
console.log();
updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd)); updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd));
}); });
} }
}); });
return [ return [
createMemo(() => store.text), createMemo(() => state.text),
{ {
select(range: Range) { select(range: Range) {
updateSelection(range); updateSelection(range);
}, },
mutate(setter) { 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);
}),
}]; }];
} }

View file

@ -1,16 +1,17 @@
import { createContextProvider } from "@solid-primitives/context"; import { createContextProvider } from "@solid-primitives/context";
import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; 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 { createSource, Source } from "../source";
import { getTextNodes } from "@solid-primitives/selection"; import { getTextNodes } from "@solid-primitives/selection";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { IndexRange } from "./map";
interface EditorContextType { interface EditorContextType {
readonly text: Accessor<string>; readonly text: Accessor<string>;
readonly selection: Accessor<Range | undefined>; readonly selection: Accessor<IndexRange>;
readonly source: Source; readonly source: Source;
select(range: Range): void; select: SelectFunction;
mutate(setter: (prev: string) => string): void; mutate: MutateFunction;
} }
interface EditorContextProps extends Record<string, unknown> { interface EditorContextProps extends Record<string, unknown> {
@ -52,7 +53,7 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
}; };
}, { }, {
text: () => '', text: () => '',
selection: () => undefined, selection: () => [undefined, undefined],
source: {} as Source, source: {} as Source,
select() { }, select() { },
mutate() { }, mutate() { },

View file

@ -2,3 +2,4 @@
export { createEditor as createEditContext } from './context'; export { createEditor as createEditContext } from './context';
export { Editor, useEditor } from './editor'; export { Editor, useEditor } from './editor';
export { splitAt } from './ast';

View file

@ -3,17 +3,16 @@ import { getTextNodes } from '@solid-primitives/selection';
import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
type IndexNode = { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }; export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number }, offset: number };
type IndexMap = IndexNode[]; export type IndexMap = IndexNode[];
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) { export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) {
// Observe the element so that the references to the nodes in the indices are updated if the DOM is changed // 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 latestMutations = observe(root);
const indices = createMemo(() => { const indices = createMemo(() => {
latestMutations(); const [node] = latestMutations();
const node = root();
if (node === undefined) { if (node === undefined) {
return []; return [];
@ -23,13 +22,36 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
}); });
return { return {
atHtmlPosition(index: number) { atHtmlPosition(range: Range): IndexRange {
return indices().find(({ html }) => html.start <= index && html.end >= index); 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] { toTextIndices(range: Range): [number, number] {
const startNode = indices().find(({ node }) => node === range.startContainer); const [startNode, endNode] = this.atHtmlPosition(range);
const endNode = indices().find(({ node }) => node === range.endContainer);
return [ return [
startNode ? (startNode.text.start + range.startOffset) : -1, 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] { toHtmlIndices(range: Range): [number, number] {
const startNode = indices().find(({ node }) => node === range.startContainer); const [startNode, endNode] = this.atHtmlPosition(range);
const endNode = indices().find(({ node }) => node === range.endContainer);
return [ return [
startNode ? (startNode.html.start + range.startOffset) : -1, 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 { toRange(start: number, end: number): Range {
const startNode = indices().find(({ html }) => html.start <= start && html.end >= start); const [startNode, endNode] = this.atTextPosition(start, end);
const endNode = indices().find(({ html }) => html.start <= end && html.end >= end);
const range = new Range(); const range = new Range();
if (startNode) { if (startNode) {
const offset = start - startNode.html.start; range.setStart(startNode.dom, startNode.offset);
range.setStart(startNode.node, offset);
} }
if (endNode) { if (endNode) {
const offset = end - endNode.html.start; range.setEnd(endNode.dom, endNode.offset);
range.setEnd(endNode.node, offset);
} }
return range; return range;
@ -75,12 +90,12 @@ const createIndices = (root: Node, ast: Root): IndexMap => {
const indices: IndexMap = []; const indices: IndexMap = [];
let index = 0; 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 { position, value } = node as Text;
const end = index + value.length; const end = index + value.length;
if (position) { 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; index = end;
@ -89,7 +104,7 @@ const createIndices = (root: Node, ast: Root): IndexMap => {
return indices; 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 [mutations, setMutations] = createSignal<MutationRecord[]>([]);
const observer = new MutationObserver(records => { const observer = new MutationObserver(records => {
@ -110,5 +125,5 @@ const observe = (node: Accessor<Node | undefined>): Accessor<MutationRecord[]> =
observer.disconnect(); observer.disconnect();
}); });
return mutations; return createMemo(() => [node(), mutations()] as const);
}; };

View 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);

View file

@ -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 { 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'; import css from './editor.module.css';
const tempVal = ` const tempVal = `
@ -49,13 +52,91 @@ export default function Formatter(props: {}) {
function Toolbar() { function Toolbar() {
const { mutate, selection } = useEditor(); 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 = () => { 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}> return <div class={css.toolbar}>
<button onclick={bold}>bold</button> <button onclick={bold}>bold</button>
</div> </div>;
} }
function SearchAndReplace() { function SearchAndReplace() {

View file

@ -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]> => { const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
if (subject instanceof Array) { if (subject instanceof Array) {
return subject.entries(); return subject.entries();