This commit is contained in:
Chris Kruining 2025-03-12 16:50:49 +01:00
parent 97036272dd
commit b1e617e74a
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
11 changed files with 476 additions and 186 deletions

View file

@ -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('.');

View file

@ -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;
}
export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => {
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) { if (index === -1) {
throw new Error('The tree does not contain the given node'); throw new Error('The tree does not contain the given node');
} }
const left = tree.children.slice(0, index); const [targetLeft, targetRight] = splitNode(remaining[index], node, offset);
const right = tree.children.slice(index + 1);
if (offset === 0) { const left = remaining.slice(0, index);
right.unshift(tree.children[index]); const right = remaining.slice(index + 1);
}
else if (offset === node.value.length) {
left.push(tree.children[index]);
}
else {
const targetLeft = deepCopy(tree.children[index]);
const targetRight = tree.children[index];
if (targetLeft) {
left.push(targetLeft); left.push(targetLeft);
right.unshift(targetRight); }
visit(targetLeft, (n): n is Text => equals(n, node), n => { if (targetRight) {
right.unshift(targetRight);
}
remaining = right;
result.push(left);
}
result.push(remaining);
return result;
};
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); n.value = n.value.slice(0, offset);
}) })
visit(targetRight, (n): n is Text => equals(n, node), n => { visit(right, (n): n is Text => equals(n, text), n => {
n.value = n.value.slice(offset); n.value = n.value.slice(offset);
}) })
}
return [left, right]; return [left, right];
};
const splitNode = (node: Node, offset: number) => {
} }
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);
}; };

View file

@ -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),
}
}), }),
}]; }];
} }

View file

@ -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() { },

View file

@ -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';

View file

@ -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;

View file

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

View file

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

View file

@ -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';

View file

@ -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;