From 925ea142fb9bb3b713b49550b45dc483bb1b23a5 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 3 Mar 2025 09:39:35 +0100 Subject: [PATCH] got started on a dedicated editor component --- src/components/textarea/textarea.module.css | 10 -- src/components/textarea/textarea.tsx | 43 +++----- src/features/editor/context.ts | 8 +- src/features/editor/editor.tsx | 99 +++++++++++++++++-- src/features/source/source.ts | 22 +---- .../experimental/formatter.module.css | 28 ++++++ .../(editor)/experimental/formatter.tsx | 33 +++++-- 7 files changed, 165 insertions(+), 78 deletions(-) diff --git a/src/components/textarea/textarea.module.css b/src/components/textarea/textarea.module.css index fadd9e2..8f7b434 100644 --- a/src/components/textarea/textarea.module.css +++ b/src/components/textarea/textarea.module.css @@ -23,16 +23,6 @@ } } -.search { - position: absolute; - inset-inline-end: 0; - inset-block-start: 0; - - display: block grid; - grid-auto-flow: row; - gap: .5em; -} - .suggestions { position-anchor: --suggestions; diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 34a2a29..ac0afe6 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,10 +1,8 @@ -import { Component, createEffect, createMemo, createSignal, For, on, untrack } from 'solid-js'; -import { createSelection, getTextNodes } from '@solid-primitives/selection'; -import { isServer } from 'solid-js/web'; +import { createEffect, createSignal, on } from 'solid-js'; +import { getTextNodes } from '@solid-primitives/selection'; import { createEditContext } from '~/features/editor'; import { createSource } from '~/features/source'; import css from './textarea.module.css'; -import { debounce } from '@solid-primitives/scheduled'; interface TextareaProps { class?: string; @@ -18,11 +16,10 @@ interface TextareaProps { } export function Textarea(props: TextareaProps) { - const [replacement, setReplacement] = createSignal(''); const [editorRef, setEditorRef] = createSignal(); const source = createSource(() => props.value); - const [text, { mutate }] = createEditContext(editorRef, () => source.out); + const [text] = createEditContext(editorRef, () => source.out); createEffect(() => { source.out = text(); @@ -44,29 +41,17 @@ export function Textarea(props: TextareaProps) { createHighlights(ref, 'search-results', errors); })); - const replace = () => { - mutate(text => text.replaceAll(source.query, replacement())); - }; - - return <> -
- source.query = e.target.value} /> - setReplacement(e.target.value)} /> - -
- -
e.stopPropagation()} - on:pointerdown={e => e.stopPropagation()} - /> - ; + return
e.stopPropagation()} + on:pointerdown={e => e.stopPropagation()} + />; } const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index 732db12..772483a 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -12,7 +12,7 @@ type Editor = [Accessor, { select(range: Range): void, mutate(setter: (t export function createEditor(ref: Accessor, value: Accessor): Editor { if (isServer) { return [value, { - select(range) { }, + select() { }, mutate() { }, }]; } @@ -101,8 +101,6 @@ export function createEditor(ref: Accessor, value: Accessor selection.removeAllRanges(); } - console.log('is it me?'); - selection.addRange(range); }); } @@ -149,7 +147,7 @@ export function createEditor(ref: Accessor, value: Accessor updateText(start, end, '    '); } else if (e.key === 'Enter') { - updateText(start, end, '\n'); + updateText(start, end, '

 '); } }, }); @@ -270,7 +268,7 @@ declare global { interface EditContextConstructor { new(): EditContext; - new(options: Partial<{ text: string, selectionStart: number, selectionEnd: number }>): EditContext; + new(options: Partial>): EditContext; readonly prototype: EditContext; } diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 870e72e..48117e0 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,20 +1,101 @@ import { createContextProvider } from "@solid-primitives/context"; -import { createEffect, ParentProps } from "solid-js"; +import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js"; import { createEditor } from "./context"; +import { createSource, Source } from "../source"; +import { getTextNodes } from "@solid-primitives/selection"; +interface EditorContextType { + text: Accessor; + readonly source: Source; + select(range: Range): void; + mutate(setter: (prev: string) => string): void; +} -const [EditorProvider, useEditor] = createContextProvider((props: { ref: Element, value: string }) => { - const [text] = createEditor(() => props.ref, () => props.value); +interface EditorContextProps extends Record { + ref: Accessor; + value: string; + oninput?: (value: string) => void; +} + +const [EditorProvider, useEditor] = createContextProvider((props) => { + const source = createSource(() => props.value); + const [text, { select, mutate }] = createEditor(props.ref, () => source.out); createEffect(() => { - console.log(text()); + props.oninput?.(source.in); }); - return { text }; -}); + createEffect(() => { + source.out = text(); + }); + + createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => { + createHighlights(ref, 'spelling-error', errors); + })); + + createEffect(on(() => [props.ref()!, source.grammarErrors] as const, ([ref, errors]) => { + createHighlights(ref, 'grammar-error', errors); + })); + + createEffect(on(() => [props.ref()!, source.queryResults] as const, ([ref, results]) => { + createHighlights(ref, 'search-results', results); + })); + + return { + text, + select, + mutate, + source, + }; +}, { text: () => '', source: {} as Source, select() { }, mutate() { } }); export { useEditor }; -export function Editor(props: ParentProps<{ ref: Element, value: string }>) { - return {props.children}; -} \ No newline at end of file +export function Editor(props: ParentProps<{ value: string, oninput?: (value: string) => void }>) { + const [ref, setRef] = createSignal(); + + return + {props.children} + + + ; +} + +function Content(props: { ref: Setter }) { + const { text } = useEditor(); + + return

; +} + +const createHighlights = (node: Node, type: string, indices: [number, number][]) => { + queueMicrotask(() => { + const nodes = getTextNodes(node); + + CSS.highlights.set(type, new Highlight(...indices.map(([start, end]) => indicesToRange(start, end, nodes)))); + }); +}; + +const indicesToRange = (start: number, end: number, textNodes: Node[]) => { + const [startNode, startPos] = getRangeArgs(start, textNodes); + const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, textNodes); + + const range = new Range(); + + if (startNode && endNode && startPos !== -1 && endPos !== -1) { + range.setStart(startNode, startPos); + range.setEnd(endNode, endPos); + } + + return range; +} + +const getRangeArgs = (offset: number, texts: Node[]): [node: Node | null, offset: number] => + texts.reduce( + ([node, pos], text) => + node + ? [node, pos] + : pos <= (text as Text).data.length + ? [text, pos] + : [null, pos - (text as Text).data.length], + [null, offset] as [node: Node | null, pos: number], + ); \ No newline at end of file diff --git a/src/features/source/source.ts b/src/features/source/source.ts index 9b8f744..3965c18 100644 --- a/src/features/source/source.ts +++ b/src/features/source/source.ts @@ -121,26 +121,14 @@ function plainTextStringify() { }; } -function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> { +function findMatches(text: string, query: string): [number, number][] { if (query.length < 1) { - return; + return []; } - let startIndex = 0; - - while (startIndex < text.length) { - const index = text.indexOf(query, startIndex); - - if (index === -1) { - break; - } - - const end = index + query.length; - - yield [index, end]; - - startIndex = end; - } + return text.matchAll(new RegExp(query, 'gi')).map<[number, number]>(({ index }) => { + return [index, index + query.length]; + }).toArray(); } const spellChecker = checker(/\w+/gi); diff --git a/src/routes/(editor)/experimental/formatter.module.css b/src/routes/(editor)/experimental/formatter.module.css index c654237..213165f 100644 --- a/src/routes/(editor)/experimental/formatter.module.css +++ b/src/routes/(editor)/experimental/formatter.module.css @@ -19,4 +19,32 @@ padding: .5em; background-color: transparent; } + + & ::highlight(search-results) { + background-color: var(--secondary-900); + } + + & ::highlight(spelling-error) { + text-decoration-line: spelling-error; + } + + & ::highlight(grammar-error) { + text-decoration-line: grammar-error; + } + + .search { + position: absolute; + inset-inline-end: 0; + inset-block-start: 0; + + display: block grid; + grid-auto-flow: row; + + padding: .5em; + gap: .5em; + + background-color: var(--surface-700); + border-radius: var(--radii-m); + box-shadow: var(--shadow-2); + } } \ No newline at end of file diff --git a/src/routes/(editor)/experimental/formatter.tsx b/src/routes/(editor)/experimental/formatter.tsx index a42441a..4a531de 100644 --- a/src/routes/(editor)/experimental/formatter.tsx +++ b/src/routes/(editor)/experimental/formatter.tsx @@ -1,8 +1,8 @@ -import { createSignal } from "solid-js"; +import { createEffect, createSignal } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; import { Textarea } from "~/components/textarea"; import css from './formatter.module.css'; -import { Editor } from "~/features/editor"; +import { Editor, useEditor } from "~/features/editor"; const tempVal = ` # Header @@ -13,15 +13,13 @@ this is *a string* that contains italicized text > Dorothy followed her through many of the beautiful rooms in her castle. -> Dorothy followed her through many of the beautiful rooms in her castle. -> -> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. - > #### The quarterly results look great! > > - Revenue was off the chart. > - Profits were higher than ever. > +> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. +> > *Everything* is going according to **plan**. - First item @@ -39,6 +37,25 @@ export default function Formatter(props: {}) { return
-