diff --git a/src/features/dataset/index.ts b/src/features/dataset/index.ts index 6c948ee..1e1ff99 100644 --- a/src/features/dataset/index.ts +++ b/src/features/dataset/index.ts @@ -1,6 +1,6 @@ import { trackStore } from "@solid-primitives/deep"; 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 { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities"; @@ -60,7 +60,7 @@ function defaultGroupingFunction(groupBy: keyof T): GroupingFunction>(data: Accessor, initialOptions?: { sort?: SortOptions, group?: GroupOptions }): DataSet => { const [state, setState] = createStore>({ - value: deepCopy(data()), + value: structuredClone(data()), snapshot: data(), sorting: initialOptions?.sort, grouping: initialOptions?.group, @@ -99,6 +99,10 @@ export const createDataSet = >(data: Accessor return deepDiff(state.snapshot, state.value).toArray(); }); + createEffect(() => { + console.log('muts', mutations()); + }); + const apply = (data: T[], mutations: Mutation[]) => { for (const mutation of mutations) { const path = mutation.key.split('.'); diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts index e909f24..6ec041f 100644 --- a/src/features/editor/ast.ts +++ b/src/features/editor/ast.ts @@ -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 { visit } from 'unist-util-visit'; -import { deepCopy } from '~/utilities'; +import { hash } from './temp'; -/** - * - * 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 createElement = (tagName: string, children: any[], properties: object = {}) => ({ type: 'element', tagName, children, properties }); -export const splitAt = (tree: Parent, node: Text, offset: number): [RootContent[], RootContent[]] => { - const index = tree.children.findIndex(c => find(c, { ...node })); +interface SplitPoint { + node: Text; + offset: number; +} - if (index === -1) { - throw new Error('The tree does not contain the given node'); +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) { + 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); - const right = tree.children.slice(index + 1); + result.push(remaining); - 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]; + return result; }; -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 => { if (a === b) { return true; @@ -86,8 +82,5 @@ const equals = (a: Node, b: Node): boolean => { 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); + return hash(a) === hash(b); }; \ No newline at end of file diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index 4868df1..a612280 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -2,14 +2,15 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } fr import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; -import { createMap, IndexRange } from './map'; -import { splice } from "~/utilities"; +import { createMap } from './map'; +import { lazy, splice } from "~/utilities"; 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 MutateFunction = (setter: (ast: Root) => Root) => void; -type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; +type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; interface EditorStoreType { isComposing: boolean; @@ -19,12 +20,21 @@ interface EditorStoreType { selectionBounds: DOMRect; } +export interface Index_Range { + startNode: Text; + startOffset: number; + endNode: Text; + endOffset: number; + + commonAncestor: () => Parent; +} + export function createEditor(ref: Accessor, value: Accessor): Editor { if (isServer) { return [value, { select() { }, mutate() { }, - selection: () => [undefined, undefined], + selection: () => undefined, }]; } @@ -83,7 +93,7 @@ export function createEditor(ref: Accessor, value: Accessor 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() { @@ -167,7 +177,7 @@ export function createEditor(ref: Accessor, value: Accessor onMount(() => { updateControlBounds(); - updateSelection(indexMap.toRange(40, 60)) + updateSelection(indexMap.fromHtmlIndices(40, 60)) }); createEffect((last?: Element) => { @@ -198,7 +208,7 @@ export function createEditor(ref: Accessor, value: Accessor if (document.activeElement === untrack(ref)) { queueMicrotask(() => { - updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd)); + updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd)); }); } }); @@ -211,17 +221,42 @@ export function createEditor(ref: Accessor, value: Accessor }, mutate(setter) { + const [start, end] = indexMap.toTextIndices(store.selection!); + 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(() => { + selection: createMemo(() => { const selection = store.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), + } }), }]; } diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 24ce40b..4f0d6d3 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,14 +1,12 @@ import { createContextProvider } from "@solid-primitives/context"; -import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; -import { createEditor, MutateFunction, SelectFunction } from "./context"; +import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js"; +import { createEditor, Index_Range, MutateFunction, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; -import { isServer } from "solid-js/web"; -import { IndexRange } from "./map"; interface EditorContextType { readonly text: Accessor; - readonly selection: Accessor; + readonly selection: Accessor; readonly source: Source; select: SelectFunction; mutate: MutateFunction; @@ -53,7 +51,7 @@ const [EditorProvider, useEditor] = createContextProvider '', - selection: () => [undefined, undefined], + selection: () => undefined, source: {} as Source, select() { }, mutate() { }, diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx index 2036329..6a7741c 100644 --- a/src/features/editor/index.tsx +++ b/src/features/editor/index.tsx @@ -1,5 +1,6 @@ +export type { Index_Range } from './context'; export { createEditor as createEditContext } from './context'; export { Editor, useEditor } from './editor'; -export { splitAt } from './ast'; \ No newline at end of file +export { splitBy, createElement, mergeNodes } from './ast'; \ No newline at end of file diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts index 36726ca..c9725cb 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -1,12 +1,13 @@ import type { Root, Text } from 'hast'; 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'; -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 IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; + export function createMap(root: Accessor, ast: Accessor) { // Observe the element so that the references to the nodes in the indices are updated if the DOM is changed const latestMutations = observe(root); @@ -22,36 +23,37 @@ export function createMap(root: Accessor, ast: Accessor 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]; + query(range: Range): [IndexNode | undefined, IndexNode | undefined] { + return [ + indices().find(({ dom }) => dom === range.startContainer), + indices().find(({ dom }) => dom === range.endContainer), + ]; }, - atTextPosition(start: number, end: number): IndexRange { - const startNode = { ...(indices().find(({ html }) => html.start <= start && html.end >= start)!) }; + atHtmlPosition(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]; + }, + + 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]; }, toTextIndices(range: Range): [number, number] { - const [startNode, endNode] = this.atHtmlPosition(range); + const [startNode, endNode] = this.query(range); return [ startNode ? (startNode.text.start + range.startOffset) : -1, @@ -60,7 +62,7 @@ export function createMap(root: Accessor, ast: Accessor, ast: Accessor { const nodes = getTextNodes(root); const indices: IndexMap = []; + console.log(ast); + let index = 0; visit(ast, (n): n is Text => n.type === 'text', (node) => { const { position, value } = node as Text; 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) { - 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; diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts index 21cb26b..d5172b4 100644 --- a/src/features/editor/state.ts +++ b/src/features/editor/state.ts @@ -28,8 +28,6 @@ export const createState = (value: Accessor): State => { }, set ast(next: Root) { - console.log(stringify(next)); - setText(stringify(next)); setAst(next); }, diff --git a/src/features/editor/temp.ts b/src/features/editor/temp.ts new file mode 100644 index 0000000..fccd7be --- /dev/null +++ b/src/features/editor/temp.ts @@ -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([], { + 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 & { 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(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 { + 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)); +}; \ No newline at end of file diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index baf687d..3b18802 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -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 { 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 findAncestor from 'unist-util-ancestor'; import type * as hast from 'hast'; import css from './editor.module.css'; @@ -41,7 +40,7 @@ export default function Formatter(props: {}) {
- + @@ -52,82 +51,41 @@ export default function Formatter(props: {}) { function Toolbar() { const { mutate, selection } = useEditor(); - const matchesAncestor = (tree: hast.Node, node: hast.Text, predicate: (node: hast.Node) => boolean) => { - let matches = false; + const trimWhitespaceOn = ({ startNode: startContainer, endNode: endContainer, startOffset, endOffset, ...rest }: Index_Range): Index_Range => { + const matchStart = startContainer.value.slice(startOffset).match(/^(\s+).*?$/); + const matchEnd = endContainer.value.slice(0, endOffset).match(/^.*?(\s+)$/); - visitParents(tree, n => n === node, (_, ancestors) => { - matches = ancestors.some(predicate); - }); - - return matches; - } + return { + startNode: startContainer, + startOffset: startOffset + (matchStart?.[1].length ?? 0), + endNode: endContainer, + endOffset: endOffset - (matchEnd?.[1].length ?? 0), + ...rest + }; + }; const bold = () => { - const [start, end] = selection(); + const range = selection(); - if (!start || !end) { - return + if (!range) { + return; } mutate((ast) => { - console.log(end.node.value.slice(0, end.offset)); + const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range); - // Trim whitespace from selection - const matchStart = start.node.value.slice(start.offset).match(/^(\s+).*?$/); - if (matchStart !== null) { - start.offset += matchStart[1].length; - } + const [left, toBold, right] = splitBy(commonAncestor(), [ + { node: startNode, offset: startOffset }, + { node: endNode, offset: endOffset }, + ]); - const matchEnd = end.node.value.slice(0, end.offset).match(/^.*?(\s+)$/); - if (matchEnd !== null) { - end.offset -= matchEnd[1].length; - } + console.log(left, toBold, right); + const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent; - // 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(); + // THIS IS WHERE I LEFT OFF + // AST needs to be clean!!!! - 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); + commonAncestor().children = [...left, boldedElement, ...right]; return ast; }); diff --git a/src/routes/(editor)/experimental/textarea.tsx b/src/routes/(editor)/experimental/textarea.tsx index e638b27..38d73d5 100644 --- a/src/routes/(editor)/experimental/textarea.tsx +++ b/src/routes/(editor)/experimental/textarea.tsx @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js"; +import { createSignal, untrack } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; import { Textarea } from "~/components/textarea"; import css from './textarea.module.css'; diff --git a/src/utilities.ts b/src/utilities.ts index ec283aa..5af38dc 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -46,6 +46,20 @@ const decodeReplacer = (_: any, char: EncodedChar) => ({ }[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]); export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer); +const LAZY_SYMBOL = Symbol('not loaded'); +export const lazy = (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 = (original: T): T => { if (typeof original !== 'object' || original === null || original === undefined) { return original;