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