stash
This commit is contained in:
parent
97036272dd
commit
b1e617e74a
11 changed files with 476 additions and 186 deletions
|
@ -1,6 +1,6 @@
|
||||||
import { trackStore } from "@solid-primitives/deep";
|
import { trackStore } from "@solid-primitives/deep";
|
||||||
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
|
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore, unwrap } from "solid-js/store";
|
||||||
import { CustomPartial } from "solid-js/store/types/store.js";
|
import { CustomPartial } from "solid-js/store/types/store.js";
|
||||||
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ function defaultGroupingFunction<T>(groupBy: keyof T): GroupingFunction<number,
|
||||||
|
|
||||||
export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>, initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
|
export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>, initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
|
||||||
const [state, setState] = createStore<DataSetState<T>>({
|
const [state, setState] = createStore<DataSetState<T>>({
|
||||||
value: deepCopy(data()),
|
value: structuredClone(data()),
|
||||||
snapshot: data(),
|
snapshot: data(),
|
||||||
sorting: initialOptions?.sort,
|
sorting: initialOptions?.sort,
|
||||||
grouping: initialOptions?.group,
|
grouping: initialOptions?.group,
|
||||||
|
@ -99,6 +99,10 @@ export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>
|
||||||
return deepDiff(state.snapshot, state.value).toArray();
|
return deepDiff(state.snapshot, state.value).toArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log('muts', mutations());
|
||||||
|
});
|
||||||
|
|
||||||
const apply = (data: T[], mutations: Mutation[]) => {
|
const apply = (data: T[], mutations: Mutation[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
const path = mutation.key.split('.');
|
const path = mutation.key.split('.');
|
||||||
|
|
|
@ -1,82 +1,78 @@
|
||||||
import type { Node, Text, Element, ElementContent, Parent, RootContent } from 'hast';
|
import type { Node, Text, Parent, RootContent } from 'hast';
|
||||||
import { find } from 'unist-util-find';
|
import { find } from 'unist-util-find';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
import { deepCopy } from '~/utilities';
|
import { hash } from './temp';
|
||||||
|
|
||||||
/**
|
export const createElement = (tagName: string, children: any[], properties: object = {}) => ({ type: 'element', tagName, children, properties });
|
||||||
*
|
|
||||||
* 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[]] => {
|
interface SplitPoint {
|
||||||
const index = tree.children.findIndex(c => find(c, { ...node }));
|
node: Text;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
if (index === -1) {
|
export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => {
|
||||||
throw new Error('The tree does not contain the given node');
|
const result: RootContent[][] = [];
|
||||||
|
let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : [];
|
||||||
|
|
||||||
|
console.log('kaas');
|
||||||
|
// console.log(Object.groupBy(splitPoints, p => hash(p.node)));
|
||||||
|
|
||||||
|
for (const { node, offset } of splitPoints) {
|
||||||
|
const index = remaining.findIndex(c => find(c, n => equals(n, node)));
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('The tree does not contain the given node');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [targetLeft, targetRight] = splitNode(remaining[index], node, offset);
|
||||||
|
|
||||||
|
const left = remaining.slice(0, index);
|
||||||
|
const right = remaining.slice(index + 1);
|
||||||
|
|
||||||
|
if (targetLeft) {
|
||||||
|
left.push(targetLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetRight) {
|
||||||
|
right.unshift(targetRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = right;
|
||||||
|
result.push(left);
|
||||||
}
|
}
|
||||||
|
|
||||||
const left = tree.children.slice(0, index);
|
result.push(remaining);
|
||||||
const right = tree.children.slice(index + 1);
|
|
||||||
|
|
||||||
if (offset === 0) {
|
return result;
|
||||||
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 splitNode = (node: Node, text: Text, offset: number): [RootContent | undefined, RootContent | undefined] => {
|
||||||
|
if (offset === 0) {
|
||||||
|
return [undefined, node as RootContent];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset === text.value.length) {
|
||||||
|
return [node as RootContent, undefined];
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = structuredClone(node) as RootContent;
|
||||||
|
const right = node as RootContent;
|
||||||
|
|
||||||
|
visit(left, (n): n is Text => equals(n, text), n => {
|
||||||
|
n.value = n.value.slice(0, offset);
|
||||||
|
})
|
||||||
|
|
||||||
|
visit(right, (n): n is Text => equals(n, text), n => {
|
||||||
|
n.value = n.value.slice(offset);
|
||||||
|
})
|
||||||
|
|
||||||
|
return [left, right];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const mergeNodes = (...nodes: Text[]): Text => {
|
||||||
|
return { type: 'text', value: nodes.map(n => n.value).join() };
|
||||||
|
};
|
||||||
|
|
||||||
const equals = (a: Node, b: Node): boolean => {
|
const equals = (a: Node, b: Node): boolean => {
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -86,8 +82,5 @@ const equals = (a: Node, b: Node): boolean => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is the nasty version of deep object checking,
|
return hash(a) === hash(b);
|
||||||
// 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,14 +2,15 @@ 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 { createMap, IndexRange } from './map';
|
import { createMap } from './map';
|
||||||
import { splice } from "~/utilities";
|
import { lazy, splice } from "~/utilities";
|
||||||
import { createState } from "./state";
|
import { createState } from "./state";
|
||||||
import type { Root } from 'hast';
|
import type { Parent, Root, Text } from 'hast';
|
||||||
|
import findAncestor from "unist-util-ancestor";
|
||||||
|
|
||||||
export type SelectFunction = (range: Range) => void;
|
export type SelectFunction = (range: Range) => void;
|
||||||
export type MutateFunction = (setter: (ast: Root) => Root) => void;
|
export type MutateFunction = (setter: (ast: Root) => Root) => void;
|
||||||
type Editor = [Accessor<string>, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor<IndexRange> }];
|
type Editor = [Accessor<string>, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor<Index_Range | undefined> }];
|
||||||
|
|
||||||
interface EditorStoreType {
|
interface EditorStoreType {
|
||||||
isComposing: boolean;
|
isComposing: boolean;
|
||||||
|
@ -19,12 +20,21 @@ interface EditorStoreType {
|
||||||
selectionBounds: DOMRect;
|
selectionBounds: DOMRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Index_Range {
|
||||||
|
startNode: Text;
|
||||||
|
startOffset: number;
|
||||||
|
endNode: Text;
|
||||||
|
endOffset: number;
|
||||||
|
|
||||||
|
commonAncestor: () => Parent;
|
||||||
|
}
|
||||||
|
|
||||||
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
|
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>): Editor {
|
||||||
if (isServer) {
|
if (isServer) {
|
||||||
return [value, {
|
return [value, {
|
||||||
select() { },
|
select() { },
|
||||||
mutate() { },
|
mutate() { },
|
||||||
selection: () => [undefined, undefined],
|
selection: () => undefined,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,7 +93,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
||||||
|
|
||||||
state.text = splice(state.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateControlBounds() {
|
function updateControlBounds() {
|
||||||
|
@ -167,7 +177,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
updateControlBounds();
|
updateControlBounds();
|
||||||
|
|
||||||
updateSelection(indexMap.toRange(40, 60))
|
updateSelection(indexMap.fromHtmlIndices(40, 60))
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect((last?: Element) => {
|
createEffect((last?: Element) => {
|
||||||
|
@ -198,7 +208,7 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
||||||
|
|
||||||
if (document.activeElement === untrack(ref)) {
|
if (document.activeElement === untrack(ref)) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd));
|
updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -211,17 +221,42 @@ export function createEditor(ref: Accessor<Element | undefined>, value: Accessor
|
||||||
},
|
},
|
||||||
|
|
||||||
mutate(setter) {
|
mutate(setter) {
|
||||||
|
const [start, end] = indexMap.toTextIndices(store.selection!);
|
||||||
|
|
||||||
state.ast = setter(state.ast);
|
state.ast = setter(state.ast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('RESTORING SELECTION')
|
||||||
|
const range = indexMap.fromTextIndices(start, end);
|
||||||
|
|
||||||
|
console.log(start, end, range);
|
||||||
|
|
||||||
|
updateSelection(range);
|
||||||
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
selection: createMemo<IndexRange>(() => {
|
selection: createMemo<Index_Range | undefined>(() => {
|
||||||
const selection = store.selection;
|
const selection = store.selection;
|
||||||
|
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
return [undefined, undefined];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return indexMap.atHtmlPosition(selection);
|
const [start, end] = indexMap.query(selection);
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startNode: start.node,
|
||||||
|
startOffset: selection.startOffset,
|
||||||
|
|
||||||
|
endNode: end.node,
|
||||||
|
endOffset: selection.endOffset,
|
||||||
|
|
||||||
|
commonAncestor: lazy(() => findAncestor(untrack(() => state.ast), [start.node, end.node]) as Parent),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
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, createSignal, on, ParentProps, Setter } from "solid-js";
|
||||||
import { createEditor, MutateFunction, SelectFunction } from "./context";
|
import { createEditor, Index_Range, 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 { IndexRange } from "./map";
|
|
||||||
|
|
||||||
interface EditorContextType {
|
interface EditorContextType {
|
||||||
readonly text: Accessor<string>;
|
readonly text: Accessor<string>;
|
||||||
readonly selection: Accessor<IndexRange>;
|
readonly selection: Accessor<Index_Range | undefined>;
|
||||||
readonly source: Source;
|
readonly source: Source;
|
||||||
select: SelectFunction;
|
select: SelectFunction;
|
||||||
mutate: MutateFunction;
|
mutate: MutateFunction;
|
||||||
|
@ -53,7 +51,7 @@ const [EditorProvider, useEditor] = createContextProvider<EditorContextType, Edi
|
||||||
};
|
};
|
||||||
}, {
|
}, {
|
||||||
text: () => '',
|
text: () => '',
|
||||||
selection: () => [undefined, undefined],
|
selection: () => undefined,
|
||||||
source: {} as Source,
|
source: {} as Source,
|
||||||
select() { },
|
select() { },
|
||||||
mutate() { },
|
mutate() { },
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
|
|
||||||
|
export type { Index_Range } from './context';
|
||||||
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';
|
export { splitBy, createElement, mergeNodes } from './ast';
|
|
@ -1,12 +1,13 @@
|
||||||
import type { Root, Text } from 'hast';
|
import type { Root, Text } from 'hast';
|
||||||
import { getTextNodes } from '@solid-primitives/selection';
|
import { getTextNodes } from '@solid-primitives/selection';
|
||||||
import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number }, offset: number };
|
export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number } };
|
||||||
export type IndexMap = IndexNode[];
|
export type IndexMap = IndexNode[];
|
||||||
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
|
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);
|
||||||
|
@ -22,36 +23,37 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
atHtmlPosition(range: Range): IndexRange {
|
query(range: Range): [IndexNode | undefined, IndexNode | undefined] {
|
||||||
const start = { ...(indices().find(({ dom }) => dom === range.startContainer)!) };
|
return [
|
||||||
const end = indices().find(({ dom }) => dom === range.endContainer);
|
indices().find(({ dom }) => dom === range.startContainer),
|
||||||
|
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 {
|
atHtmlPosition(start: number, end: number): IndexRange {
|
||||||
const startNode = { ...(indices().find(({ html }) => html.start <= start && html.end >= start)!) };
|
const startNode = indices().find(({ html }) => html.start <= start && html.end >= start);
|
||||||
const endNode = indices().find(({ html }) => html.start <= end && html.end >= end);
|
const endNode = indices().find(({ html }) => html.start <= end && html.end >= end);
|
||||||
|
|
||||||
if (!startNode || !endNode) {
|
if (!startNode || !endNode) {
|
||||||
return [undefined, undefined];
|
return [undefined, undefined];
|
||||||
}
|
}
|
||||||
|
|
||||||
startNode.offset = start - startNode.html.start;
|
return [startNode, endNode];
|
||||||
endNode.offset = end - endNode.html.start;
|
},
|
||||||
|
|
||||||
|
atTextPosition(start: number, end: number): IndexRange {
|
||||||
|
const startNode = indices().find(({ text }) => text.start <= start && text.end >= start);
|
||||||
|
const endNode = indices().find(({ text }) => text.start <= end && text.end >= end);
|
||||||
|
|
||||||
|
if (!startNode || !endNode) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
|
||||||
return [startNode, endNode];
|
return [startNode, endNode];
|
||||||
},
|
},
|
||||||
|
|
||||||
toTextIndices(range: Range): [number, number] {
|
toTextIndices(range: Range): [number, number] {
|
||||||
const [startNode, endNode] = this.atHtmlPosition(range);
|
const [startNode, endNode] = this.query(range);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
startNode ? (startNode.text.start + range.startOffset) : -1,
|
startNode ? (startNode.text.start + range.startOffset) : -1,
|
||||||
|
@ -60,7 +62,7 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
||||||
},
|
},
|
||||||
|
|
||||||
toHtmlIndices(range: Range): [number, number] {
|
toHtmlIndices(range: Range): [number, number] {
|
||||||
const [startNode, endNode] = this.atHtmlPosition(range);
|
const [startNode, endNode] = this.query(range);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
startNode ? (startNode.html.start + range.startOffset) : -1,
|
startNode ? (startNode.html.start + range.startOffset) : -1,
|
||||||
|
@ -68,16 +70,41 @@ export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Roo
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
toRange(start: number, end: number): Range {
|
fromTextIndices(start: number, end: number): Range {
|
||||||
const [startNode, endNode] = this.atTextPosition(start, end);
|
const [startNode, endNode] = this.atTextPosition(start, end);
|
||||||
const range = new Range();
|
const range = new Range();
|
||||||
|
|
||||||
if (startNode) {
|
if (startNode) {
|
||||||
range.setStart(startNode.dom, startNode.offset);
|
const offset = start - startNode.text.start;
|
||||||
|
|
||||||
|
range.setStart(startNode.dom, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endNode) {
|
if (endNode) {
|
||||||
range.setEnd(endNode.dom, endNode.offset);
|
const offset = end - endNode.text.start;
|
||||||
|
|
||||||
|
console.log('end offset', endNode);
|
||||||
|
|
||||||
|
range.setEnd(endNode.dom, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return range;
|
||||||
|
},
|
||||||
|
|
||||||
|
fromHtmlIndices(start: number, end: number): Range {
|
||||||
|
const [startNode, endNode] = this.atHtmlPosition(start, end);
|
||||||
|
const range = new Range();
|
||||||
|
|
||||||
|
if (startNode) {
|
||||||
|
const offset = start - startNode.html.start;
|
||||||
|
|
||||||
|
range.setStart(startNode.dom, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endNode) {
|
||||||
|
const offset = end - endNode.html.start;
|
||||||
|
|
||||||
|
range.setEnd(endNode.dom, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return range;
|
return range;
|
||||||
|
@ -89,13 +116,22 @@ const createIndices = (root: Node, ast: Root): IndexMap => {
|
||||||
const nodes = getTextNodes(root);
|
const nodes = getTextNodes(root);
|
||||||
const indices: IndexMap = [];
|
const indices: IndexMap = [];
|
||||||
|
|
||||||
|
console.log(ast);
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
visit(ast, (n): n is Text => 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;
|
||||||
|
const dom = nodes.shift()!;
|
||||||
|
|
||||||
|
console.log({ value, text: dom?.textContent, dom });
|
||||||
|
|
||||||
|
// if (value.includes('ntains bolded text')) {
|
||||||
|
// console.log(value, dom.textContent, { node, dom });
|
||||||
|
// }
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
indices.push({ node, dom: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! }, offset: 0 });
|
indices.push({ node, dom, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } });
|
||||||
}
|
}
|
||||||
|
|
||||||
index = end;
|
index = end;
|
||||||
|
|
|
@ -28,8 +28,6 @@ export const createState = (value: Accessor<string>): State => {
|
||||||
},
|
},
|
||||||
|
|
||||||
set ast(next: Root) {
|
set ast(next: Root) {
|
||||||
console.log(stringify(next));
|
|
||||||
|
|
||||||
setText(stringify(next));
|
setText(stringify(next));
|
||||||
setAst(next);
|
setAst(next);
|
||||||
},
|
},
|
||||||
|
|
253
src/features/editor/temp.ts
Normal file
253
src/features/editor/temp.ts
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
const bit = {
|
||||||
|
get(subject: number, index: number) {
|
||||||
|
return Boolean((subject >> index) & 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
set(subject: number, index: number, value?: boolean) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
return this.clear(subject, index) | ((value ? 1 : 0) << index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return subject | (1 << index)
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(subject: number, index: number) {
|
||||||
|
return subject & ~(1 << index);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(subject: number, index: number) {
|
||||||
|
return subject ^ (1 << index);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BitArray {
|
||||||
|
[index: number]: boolean;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_BIT_SIZE = 64;
|
||||||
|
const createBitArray = (data: boolean[] = []) => {
|
||||||
|
const store: number[] = [];
|
||||||
|
const populated: number[] = [];
|
||||||
|
let length = 0;
|
||||||
|
|
||||||
|
const parseIndex = (key: string) => {
|
||||||
|
const value = Number.parseInt(key);
|
||||||
|
|
||||||
|
if (Number.isNaN(value) || !Number.isFinite(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convert = (index: number) => [
|
||||||
|
Math.floor(index / ITEM_BIT_SIZE),
|
||||||
|
index % ITEM_BIT_SIZE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const get = (index: number) => {
|
||||||
|
if (index >= length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [arrayIndex, bitIndex] = convert(index);
|
||||||
|
|
||||||
|
if (bit.get(populated[arrayIndex], bitIndex) === false) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bit.get(store[arrayIndex], bitIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (index: number, value: boolean) => {
|
||||||
|
const [arrayIndex, bitIndex] = convert(index);
|
||||||
|
|
||||||
|
store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, value);
|
||||||
|
populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex);
|
||||||
|
length = Math.max(length, index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = (index: number) => {
|
||||||
|
const [arrayIndex, bitIndex] = convert(index);
|
||||||
|
|
||||||
|
// I think I can skip the store because it is covered by the populated list
|
||||||
|
// store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, false);
|
||||||
|
populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex, false);
|
||||||
|
length = Math.max(length, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial population of array
|
||||||
|
for (const [i, v] of data.entries()) {
|
||||||
|
set(i, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Proxy<BitArray>([], {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property === Symbol.species) {
|
||||||
|
return 'BitArray'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof property === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseIndex(property);
|
||||||
|
|
||||||
|
if (index) {
|
||||||
|
console.log(store.map(i => i.toString(2)), populated.map(i => i.toString(2)));
|
||||||
|
|
||||||
|
return get(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(property, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
set(target, property, value, receiver) {
|
||||||
|
if (typeof property === 'symbol') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseIndex(property);
|
||||||
|
|
||||||
|
if (index) {
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
throw new Error(`Only able to set boolean values on indices, received '${typeof value}' instead`)
|
||||||
|
}
|
||||||
|
|
||||||
|
set(index, value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProperty(target, property) {
|
||||||
|
if (typeof property === 'symbol') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseIndex(property);
|
||||||
|
|
||||||
|
if (index) {
|
||||||
|
clear(index);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_SIZE = 512;
|
||||||
|
const CHUNK_SIZE = 16;
|
||||||
|
const UINT32_BYTE_SIZE = 4;
|
||||||
|
const HASH_NUMBER_OF_UINT32 = 5;
|
||||||
|
const HASH_SIZE = HASH_NUMBER_OF_UINT32 * UINT32_BYTE_SIZE;
|
||||||
|
const initalizationVector /* 20 bytes */ = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0] as const;
|
||||||
|
const hashKey /* 16 bytes */ = [0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6] as const;
|
||||||
|
|
||||||
|
type Word = number & {}; // union with empty object so typescript show this as 'Word' and not as 'number'
|
||||||
|
type Chunk = Iterable<Word> & { length: typeof HASH_NUMBER_OF_UINT32 };
|
||||||
|
type HashBytes = Uint32Array & { length: typeof HASH_NUMBER_OF_UINT32 };
|
||||||
|
|
||||||
|
const _hash = (data: string | Uint8Array | Uint32Array) => {
|
||||||
|
// Normalize data to byte array
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to Uint32Array
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
data = new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(data.length)) {
|
||||||
|
throw new Error('Cannot hash more than 2^53 - 1 bits');
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare blocks
|
||||||
|
const output = new Uint32Array(initalizationVector) as HashBytes;
|
||||||
|
const blocks = range(0, data.length, CHUNK_SIZE, true).map(i => {
|
||||||
|
const view = data.subarray(i, i + 16);
|
||||||
|
const words = Array<Word>(80);
|
||||||
|
|
||||||
|
words[0] = view[0];
|
||||||
|
words[1] = view[1];
|
||||||
|
words[2] = view[2];
|
||||||
|
words[3] = view[3];
|
||||||
|
words[4] = view[4];
|
||||||
|
|
||||||
|
return words;
|
||||||
|
});
|
||||||
|
|
||||||
|
// apply blocks
|
||||||
|
for (const words of blocks) {
|
||||||
|
let [a, b, c, d, e] = output;
|
||||||
|
|
||||||
|
for (const index of range(0, 80)) {
|
||||||
|
if (index >= 16) {
|
||||||
|
words[index] = circularShiftLeft(1, words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = (
|
||||||
|
circularShiftLeft(a, HASH_NUMBER_OF_UINT32) +
|
||||||
|
logicalHashFunctions(index, b, c, d) +
|
||||||
|
e +
|
||||||
|
words[index] +
|
||||||
|
hashKey[Math.floor(index / HASH_SIZE)]
|
||||||
|
);
|
||||||
|
|
||||||
|
e = d;
|
||||||
|
d = c;
|
||||||
|
c = circularShiftLeft(b, 30);
|
||||||
|
b = a;
|
||||||
|
a = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[0] = (output[0] + a) | 0;
|
||||||
|
output[1] = (output[1] + b) | 0;
|
||||||
|
output[2] = (output[2] + c) | 0;
|
||||||
|
output[3] = (output[3] + d) | 0;
|
||||||
|
output[4] = (output[4] + e) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.values().map(word => (word >>> 0).toString(16)).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const circularShiftLeft = (subject: number, offset: number): number => {
|
||||||
|
return ((subject << offset) | (subject >>> 32 - offset)) & (0xFFFFFFFF);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logicalHashFunctions = (index: number, b: Word, c: Word, d: Word): Word => {
|
||||||
|
if (index < HASH_SIZE) {
|
||||||
|
return (b & c) | (~b & d);
|
||||||
|
}
|
||||||
|
else if (index < (2 * HASH_SIZE)) {
|
||||||
|
return b ^ c ^ d;
|
||||||
|
}
|
||||||
|
else if (index < (3 * HASH_SIZE)) {
|
||||||
|
return (b & c) | (b & d) | (c & d);
|
||||||
|
}
|
||||||
|
else if (index < (4 * HASH_SIZE)) {
|
||||||
|
return b ^ c ^ d;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unreachable code');
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = function* (start: number, end: number, step: number = 1, inclusive: boolean = false): Iterator<number> {
|
||||||
|
for (let i = start; inclusive ? (i <= end) : (i < end); i += (step ?? 1)) {
|
||||||
|
yield i;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hash = (data: any): string => {
|
||||||
|
if (typeof data === 'string' || (typeof data === 'object' && (data instanceof Uint8Array || data instanceof Uint32Array))) {
|
||||||
|
return _hash(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _hash(JSON.stringify(data));
|
||||||
|
};
|
|
@ -1,8 +1,7 @@
|
||||||
import { createEffect, createMemo, createSignal } from "solid-js";
|
import { createEffect, createMemo, createSignal, untrack } from "solid-js";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
import { Editor, splitAt, useEditor } from "~/features/editor";
|
import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor";
|
||||||
import { visitParents } from "unist-util-visit-parents";
|
import { visitParents } from "unist-util-visit-parents";
|
||||||
import findAncestor from 'unist-util-ancestor';
|
|
||||||
import type * as hast from 'hast';
|
import type * as hast from 'hast';
|
||||||
import css from './editor.module.css';
|
import css from './editor.module.css';
|
||||||
|
|
||||||
|
@ -41,7 +40,7 @@ export default function Formatter(props: {}) {
|
||||||
<textarea oninput={onInput} title="markdown">{value()}</textarea>
|
<textarea oninput={onInput} title="markdown">{value()}</textarea>
|
||||||
|
|
||||||
<div class={css.editor}>
|
<div class={css.editor}>
|
||||||
<Editor value={value()} oninput={setValue}>
|
<Editor value={untrack(value)} oninput={setValue}>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<SearchAndReplace />
|
<SearchAndReplace />
|
||||||
</Editor>
|
</Editor>
|
||||||
|
@ -52,82 +51,41 @@ 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) => {
|
const trimWhitespaceOn = ({ startNode: startContainer, endNode: endContainer, startOffset, endOffset, ...rest }: Index_Range): Index_Range => {
|
||||||
let matches = false;
|
const matchStart = startContainer.value.slice(startOffset).match(/^(\s+).*?$/);
|
||||||
|
const matchEnd = endContainer.value.slice(0, endOffset).match(/^.*?(\s+)$/);
|
||||||
|
|
||||||
visitParents(tree, n => n === node, (_, ancestors) => {
|
return {
|
||||||
matches = ancestors.some(predicate);
|
startNode: startContainer,
|
||||||
});
|
startOffset: startOffset + (matchStart?.[1].length ?? 0),
|
||||||
|
endNode: endContainer,
|
||||||
return matches;
|
endOffset: endOffset - (matchEnd?.[1].length ?? 0),
|
||||||
}
|
...rest
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const bold = () => {
|
const bold = () => {
|
||||||
const [start, end] = selection();
|
const range = selection();
|
||||||
|
|
||||||
if (!start || !end) {
|
if (!range) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutate((ast) => {
|
mutate((ast) => {
|
||||||
console.log(end.node.value.slice(0, end.offset));
|
const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range);
|
||||||
|
|
||||||
// Trim whitespace from selection
|
const [left, toBold, right] = splitBy(commonAncestor(), [
|
||||||
const matchStart = start.node.value.slice(start.offset).match(/^(\s+).*?$/);
|
{ node: startNode, offset: startOffset },
|
||||||
if (matchStart !== null) {
|
{ node: endNode, offset: endOffset },
|
||||||
start.offset += matchStart[1].length;
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
const matchEnd = end.node.value.slice(0, end.offset).match(/^.*?(\s+)$/);
|
console.log(left, toBold, right);
|
||||||
if (matchEnd !== null) {
|
const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent;
|
||||||
end.offset -= matchEnd[1].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge case Unbold the selected characters
|
// THIS IS WHERE I LEFT OFF
|
||||||
if (start.node === end.node) {
|
// AST needs to be clean!!!!
|
||||||
visitParents(ast, (n): n is hast.Text => n === start.node, (n, ancestors) => {
|
|
||||||
const [strong, parent] = ancestors.toReversed();
|
|
||||||
|
|
||||||
if (strong.type === 'element' && strong.tagName === 'strong') {
|
commonAncestor().children = [...left, boldedElement, ...right];
|
||||||
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 ast;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal, untrack } from "solid-js";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
import { Textarea } from "~/components/textarea";
|
import { Textarea } from "~/components/textarea";
|
||||||
import css from './textarea.module.css';
|
import css from './textarea.module.css';
|
||||||
|
|
|
@ -46,6 +46,20 @@ const decodeReplacer = (_: any, char: EncodedChar) => ({
|
||||||
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
||||||
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
||||||
|
|
||||||
|
const LAZY_SYMBOL = Symbol('not loaded');
|
||||||
|
export const lazy = <T>(fn: () => T): (() => T) => {
|
||||||
|
let value: T | symbol = LAZY_SYMBOL;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (value === LAZY_SYMBOL) {
|
||||||
|
value = fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated just use structuredClone instead */
|
||||||
export const deepCopy = <T>(original: T): T => {
|
export const deepCopy = <T>(original: T): T => {
|
||||||
if (typeof original !== 'object' || original === null || original === undefined) {
|
if (typeof original !== 'object' || original === null || original === undefined) {
|
||||||
return original;
|
return original;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue