diff --git a/src/entry-client.tsx b/src/entry-client.tsx index b613fae..1639f34 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,5 +1,8 @@ // @refresh reload import { mount, StartClient } from "@solidjs/start/client"; +import { installIntoGlobal } from "iterator-helpers-polyfill"; import 'solid-devtools'; +installIntoGlobal(); + mount(() => , document.body); diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts index ea08fb9..1ff0afa 100644 --- a/src/features/editor/ast.ts +++ b/src/features/editor/ast.ts @@ -13,17 +13,21 @@ interface SplitPoint { export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => { const result: RootContent[][] = []; let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : []; - - // console.log(Object.groupBy(splitPoints, p => hash(p.node))); + let lastNode; + let accumulatedOffset = 0; for (const { node, offset } of splitPoints) { + if (lastNode !== node) { + accumulatedOffset = 0; + } + 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 [targetLeft, targetRight] = splitNode(remaining[index], node, offset - accumulatedOffset); const left = remaining.slice(0, index); const right = remaining.slice(index + 1); @@ -38,6 +42,9 @@ export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][ remaining = right; result.push(left); + + lastNode = node; + accumulatedOffset += offset; } result.push(remaining); diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index a612280..1fcbfa9 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -1,16 +1,13 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; -import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; +import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; import { createMap } from './map'; -import { lazy, splice } from "~/utilities"; -import { createState } from "./state"; -import type { Parent, Root, Text } from 'hast'; -import findAncestor from "unist-util-ancestor"; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; 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 = { select: SelectFunction, readonly selection: Accessor }; interface EditorStoreType { isComposing: boolean; @@ -20,22 +17,12 @@ 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, { + return { select() { }, - mutate() { }, selection: () => undefined, - }]; + }; } if (!("EditContext" in window)) { @@ -46,8 +33,9 @@ export function createEditor(ref: Accessor, value: Accessor text: value(), }); - const state = createState(value); - const indexMap = createMap(() => ref()!, () => state.ast); + const mutations = observe(ref); + const ast = createMemo(() => parse(value())); + const indexMap = createMap(ref, ast); const [store, setStore] = createStore({ isComposing: false, selection: undefined, @@ -58,13 +46,30 @@ export function createEditor(ref: Accessor, value: Accessor selectionBounds: new DOMRect(), }); + createEffect(on(mutations, () => { + const selection = store.selection; + + if (selection === undefined) { + return + } + + queueMicrotask(() => { + console.log(selection); + + updateSelection(selection); + }); + })); + createEventListenerMap(context, { textupdate(e: TextUpdateEvent) { - const { updateRangeStart: start, updateRangeEnd: end, text } = e; + const selection = store.selection; - setStore('text', `${store.text.slice(0, start)}${text}${store.text.slice(end)}`); + if (!selection) { + return; + } - context.updateSelection(start + text.length, start + text.length); + selection.insertNode(document.createTextNode(e.text)); + selection.collapse(); }, compositionstart() { @@ -88,20 +93,20 @@ export function createEditor(ref: Accessor, value: Accessor }, }); - function updateText(start: number, end: number, text: string) { - context.updateText(start, end, text); - - state.text = splice(state.text, start, end, text); - - // context.updateSelection(start + text.length, start + text.length); - } - function updateControlBounds() { context.updateControlBounds(ref()!.getBoundingClientRect()); } function updateSelection(range: Range) { - context.updateSelection(...indexMap.toHtmlIndices(range)); + const [start, end] = indexMap.query(range); + + console.log(start, end, range); + + if (!start || !end) { + return; + } + + context.updateSelection(start.start + range.startOffset, end.start + range.endOffset); context.updateSelectionBounds(range.getBoundingClientRect()); setStore('selection', range); @@ -158,6 +163,8 @@ export function createEditor(ref: Accessor, value: Accessor // keyCode === 229 is a special code that indicates an IME event. // https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime if (e.keyCode === 229) { + console.log(e); + return; } @@ -167,9 +174,9 @@ export function createEditor(ref: Accessor, value: Accessor if (e.key === 'Tab') { e.preventDefault(); - updateText(start, end, '    '); + context.updateText(start, end, '    '); } else if (e.key === 'Enter') { - updateText(start, end, '

 '); + context.updateText(start, end, '

 '); } }, }); @@ -177,7 +184,8 @@ export function createEditor(ref: Accessor, value: Accessor onMount(() => { updateControlBounds(); - updateSelection(indexMap.fromHtmlIndices(40, 60)) + // updateSelection(indexMap.fromHtmlIndices(40, 60)) + // updateSelection(indexMap.fromHtmlIndices(599, 603)) }); createEffect((last?: Element) => { @@ -196,70 +204,43 @@ export function createEditor(ref: Accessor, value: Accessor return el; }); - createEffect(() => { + return { + select(range: Range) { + updateSelection(range); + }, + + selection: createMemo(() => { + return store.selection; + }), + }; +} + +const observe = (node: Accessor): Accessor => { + const [mutations, setMutations] = createSignal([]); + + const observer = new MutationObserver(records => { + setMutations(records); }); createEffect(() => { - updateText(0, -0, value()); - }); + const n = node(); - createEffect(() => { - state.text; + observer.disconnect(); - if (document.activeElement === untrack(ref)) { - queueMicrotask(() => { - updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd)); - }); + if (n) { + observer.observe(n, { characterData: true, subtree: true, childList: true }); } }); - return [ - createMemo(() => state.text), - { - select(range: Range) { - updateSelection(range); - }, + onCleanup(() => { + observer.disconnect(); + }); - mutate(setter) { - const [start, end] = indexMap.toTextIndices(store.selection!); + return createMemo(() => [node(), mutations()] as const); +}; - 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(() => { - const selection = store.selection; - - if (!selection) { - return undefined; - } - - 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), - } - }), - }]; -} +const parseProcessor = unified().use(rehypeParse) +const parse = (text: string) => parseProcessor.parse(text); const equals = (a: Range, b: Range): boolean => { const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer']; diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 4f0d6d3..67b3fab 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,15 +1,14 @@ import { createContextProvider } from "@solid-primitives/context"; -import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js"; -import { createEditor, Index_Range, MutateFunction, SelectFunction } from "./context"; +import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; +import { createEditor, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; interface EditorContextType { readonly text: Accessor; - readonly selection: Accessor; + readonly selection: Accessor; readonly source: Source; select: SelectFunction; - mutate: MutateFunction; } interface EditorContextProps extends Record { @@ -20,16 +19,12 @@ interface EditorContextProps extends Record { const [EditorProvider, useEditor] = createContextProvider((props) => { const source = createSource(() => props.value); - const [text, { select, mutate, selection }] = createEditor(props.ref, () => source.out); + const { select, selection } = createEditor(props.ref, () => source.out); createEffect(() => { props.oninput?.(source.in); }); - createEffect(() => { - source.out = text(); - }); - createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => { createHighlights(ref, 'spelling-error', errors); })); @@ -43,9 +38,8 @@ const [EditorProvider, useEditor] = createContextProvider source.out), select, - mutate, source, selection, }; @@ -54,7 +48,6 @@ const [EditorProvider, useEditor] = createContextProvider undefined, source: {} as Source, select() { }, - mutate() { }, }); export { useEditor }; @@ -72,6 +65,12 @@ export function Editor(props: ParentProps<{ value: string, oninput?: (value: str function Content(props: { ref: Setter }) { const { text } = useEditor(); + createEffect(() => { + text(); + + console.error('rerendering'); + }); + return

; } diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx index 6a7741c..d0068de 100644 --- a/src/features/editor/index.tsx +++ b/src/features/editor/index.tsx @@ -1,6 +1,3 @@ - - -export type { Index_Range } from './context'; export { createEditor as createEditContext } from './context'; export { Editor, useEditor } from './editor'; 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 c9725cb..9923233 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -7,159 +7,37 @@ export type IndexNode = { node: Text, dom: Node, text: { start: number, end: num 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(() => { - const [node] = latestMutations(); + const mapping = createMemo(() => { + const node = root(); + const tree = ast(); if (node === undefined) { - return []; + return new WeakMap(); } - return createIndices(node, ast()); + console.warn('recalculating map'); + + return createMapping(node, tree); }); return { - query(range: Range): [IndexNode | undefined, IndexNode | undefined] { + query: (range: Range) => { return [ - indices().find(({ dom }) => dom === range.startContainer), - indices().find(({ dom }) => dom === range.endContainer), + mapping().get(range.startContainer), + mapping().get(range.endContainer), ]; }, - - 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]; - } - - 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.query(range); - - return [ - startNode ? (startNode.text.start + range.startOffset) : -1, - endNode ? (endNode.text.start + range.endOffset) : -1 - ]; - }, - - toHtmlIndices(range: Range): [number, number] { - const [startNode, endNode] = this.query(range); - - return [ - startNode ? (startNode.html.start + range.startOffset) : -1, - endNode ? (endNode.html.start + range.endOffset) : -1 - ]; - }, - - fromTextIndices(start: number, end: number): Range { - const [startNode, endNode] = this.atTextPosition(start, end); - const range = new Range(); - - if (startNode) { - const offset = start - startNode.text.start; - - range.setStart(startNode.dom, offset); - } - - if (endNode) { - 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; - }, }; } -const createIndices = (root: Node, ast: Root): IndexMap => { +const createMapping = (root: Node, ast: Root): WeakMap => { const nodes = getTextNodes(root); - const indices: IndexMap = []; + const map = new WeakMap(); - 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, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } }); - } - - index = end; + map.set(nodes.shift()!, { start: node.position!.start.offset, end: node.position!.end.offset, text: node.value }) }); - return indices; -}; - -const observe = (node: Accessor): Accessor => { - const [mutations, setMutations] = createSignal([]); - - const observer = new MutationObserver(records => { - setMutations(records); - }); - - createEffect(() => { - const n = node(); - - observer.disconnect(); - - if (n) { - observer.observe(n, { characterData: true, subtree: true, childList: true }); - } - }); - - onCleanup(() => { - observer.disconnect(); - }); - - return createMemo(() => [node(), mutations()] as const); + return map; }; \ No newline at end of file diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts deleted file mode 100644 index d5172b4..0000000 --- a/src/features/editor/state.ts +++ /dev/null @@ -1,41 +0,0 @@ -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) { - 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/features/file/helpers.ts b/src/features/file/helpers.ts index a4b68bd..17a4801 100644 --- a/src/features/file/helpers.ts +++ b/src/features/file/helpers.ts @@ -2,11 +2,8 @@ import { Accessor, createEffect, from, createSignal } from "solid-js"; import { json } from "./parser"; import { filter } from "~/utilities"; import { isServer } from "solid-js/web"; -import { installIntoGlobal } from 'iterator-helpers-polyfill'; import { debounce } from "@solid-primitives/scheduled"; -installIntoGlobal(); - interface Files extends Record { } interface Contents extends Map> { } diff --git a/src/routes/(editor)/experimental/editor.module.css b/src/routes/(editor)/experimental/editor.module.css index f756d0b..6c4c873 100644 --- a/src/routes/(editor)/experimental/editor.module.css +++ b/src/routes/(editor)/experimental/editor.module.css @@ -20,6 +20,11 @@ background-color: transparent; } + & ::highlight(debug) { + text-decoration: double underline; + text-decoration-color: cornflowerblue; + } + & ::highlight(search-results) { background-color: var(--secondary-900); } diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index 3b18802..d7aa2a2 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -1,9 +1,8 @@ -import { createEffect, createMemo, createSignal, untrack } from "solid-js"; +import { createEffect, createMemo, createSignal, onMount, untrack } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; -import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor"; -import { visitParents } from "unist-util-visit-parents"; -import type * as hast from 'hast'; +import { Editor, useEditor } from "~/features/editor"; import css from './editor.module.css'; +import { assert } from "~/utilities"; const tempVal = ` # Header @@ -49,49 +48,39 @@ export default function Formatter(props: {}) { } function Toolbar() { - const { mutate, selection } = useEditor(); - - 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+)$/); - - return { - startNode: startContainer, - startOffset: startOffset + (matchStart?.[1].length ?? 0), - endNode: endContainer, - endOffset: endOffset - (matchEnd?.[1].length ?? 0), - ...rest - }; - }; - const bold = () => { - const range = selection(); + const range = window.getSelection()!.getRangeAt(0); + // const { startContainer, startOffset, endContainer, endOffset, commonAncestorContainer } = range; + // console.log(startContainer, startOffset, endContainer, endOffset, commonAncestorContainer); - if (!range) { + if (range.startContainer.nodeType !== Node.TEXT_NODE) { return; } - mutate((ast) => { - const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range); + if (range.endContainer.nodeType !== Node.TEXT_NODE) { + return; + } - const [left, toBold, right] = splitBy(commonAncestor(), [ - { node: startNode, offset: startOffset }, - { node: endNode, offset: endOffset }, - ]); + const fragment = range.extractContents(); - console.log(left, toBold, right); - const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent; - - // THIS IS WHERE I LEFT OFF - // AST needs to be clean!!!! - - commonAncestor().children = [...left, boldedElement, ...right]; - - return ast; - }); + if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') { + range.selectNode(range.commonAncestorContainer.parentElement); + range.insertNode(fragment); + } + else { + const strong = document.createElement('strong'); + strong.append(fragment); + range.insertNode(strong); + } }; + onMount(() => { + queueMicrotask(() => { + // bold(); + }); + }); + return
; diff --git a/src/utilities.ts b/src/utilities.ts index 5af38dc..bc36f57 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -1,3 +1,9 @@ +export const assert = (assertion: boolean, message: string) => { + if (assertion !== true) { + throw new Error(message); + } +} + export const splice = (subject: string, start: number, end: number, replacement: string) => { return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`; };