getting the hang of the editContext api

This commit is contained in:
Chris Kruining 2025-02-24 17:01:47 +11:00
parent 213a1f7ae7
commit 4fb7405466
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
11 changed files with 587 additions and 134 deletions

View file

@ -0,0 +1,324 @@
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener";
import { Accessor, createEffect, createMemo, onMount } from "solid-js";
import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web";
import { createSelection, getTextNodes } from "@solid-primitives/selection";
import { visit } from "unist-util-visit";
import type { Root, Text } from 'hast';
import { unified } from "unified";
import rehypeParse from "rehype-parse";
type EditContext = [Accessor<string>];
export function createEditContext(ref: Accessor<HTMLElement | undefined>, value: Accessor<string>): EditContext {
if (isServer) {
return [createMemo(() => value())];
}
if (!("EditContext" in window)) {
throw new Error('`EditContext` is not implemented');
}
const context = new EditContext({
text: value(),
});
const [store, setStore] = createStore({
text: value(),
isComposing: false,
// Bounds
characterBounds: new Array<DOMRect>(),
controlBounds: new DOMRect(),
selectionBounds: new DOMRect(),
});
const ast = createMemo(() => unified().use(rehypeParse).parse(store.text));
const indices = createMemo(() => {
const root = ref();
if (!root) {
return [];
}
const nodes = getTextNodes(root);
const indices: { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }[] = [];
let index = 0;
visit(ast(), n => 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! } });
}
index = end;
});
return indices;
});
const [selection, setSelection] = createSelection();
createEffect(() => {
console.log(indices());
});
createEventListenerMap<any>(context, {
textupdate(e: TextUpdateEvent) {
const { updateRangeStart: start, updateRangeEnd: end } = e;
setStore('text', `${store.text.slice(0, start)}${e.text}${store.text.slice(end)}`);
updateSelection(toRange(ref()!, start, end));
setTimeout(() => {
console.log('hmmm', e, start, end);
context.updateSelection(start, end);
setSelection([ref()!, start, end]);
}, 1000);
},
compositionstart() {
setStore('isComposing', true);
},
compositionend() {
setStore('isComposing', false);
},
characterboundsupdate(e: CharacterBoundsUpdateEvent) {
context.updateCharacterBounds(e.rangeStart, []);
},
textformatupdate(e: TextFormatUpdateEvent) {
const formats = e.getTextFormats();
for (const format of formats) {
console.log(format);
}
},
});
function updateControlBounds() {
context.updateControlBounds(ref()!.getBoundingClientRect());
}
function updateSelection(range: Range) {
const [start, end] = toIndices(ref()!, range);
let index = 0;
let mappedStart = -1;
let mappedEnd = -1;
visit(ast(), n => n.type === 'text', (node) => {
const { position, value } = node as Text;
if (position) {
if (index <= start && (index + value.length) >= start) {
mappedStart = position.start.offset! + range.startOffset;
}
if (index <= end && (index + value.length) >= end) {
mappedEnd = position.start.offset! + range.endOffset;
}
}
index += value.length;
});
context.updateSelection(mappedStart, mappedEnd);
context.updateSelectionBounds(range.getBoundingClientRect());
setSelection([ref()!, start, end]);
}
WindowEventListener({
onresize() {
updateControlBounds()
},
});
createEventListenerMap(() => ref()!, {
keydown(e: KeyboardEvent) {
// 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) {
return;
}
const start = context.selectionStart;
const end = context.selectionEnd;
if (e.key === 'Tab') {
e.preventDefault();
context.updateText(start, end, '\t');
// updateSelection(start + 1, start + 1);
} else if (e.key === 'Enter') {
context.updateText(start, end, '\n');
// updateSelection(start + 1, start + 1);
}
},
});
DocumentEventListener({
onSelectionchange(e) {
const selection = document.getSelection()!;
if (selection.rangeCount < 1) {
return;
}
if (document.activeElement !== ref()) {
return;
}
updateSelection(selection.getRangeAt(0)!);
},
});
onMount(() => {
updateControlBounds();
});
createEffect((last?: HTMLElement) => {
if (last !== undefined) {
last.editContext = undefined;
}
const el = ref();
if (el === undefined) {
return;
}
el.editContext = context;
return el;
});
createEffect(() => {
context.updateText(0, 0, value());
});
return [createMemo(() => store.text)];
}
declare global {
interface HTMLElement {
editContext?: EditContext;
}
interface TextFormat {
readonly rangeStart: number;
readonly rangeEnd: number;
readonly underlineStyle: 'none' | 'solid' | 'double' | 'dotted' | 'sadhed' | 'wavy';
readonly underlineThickness: 'none' | 'thin' | 'thick';
}
interface CharacterBoundsUpdateEvent extends Event {
readonly rangeStart: number;
readonly rangeEnd: number;
}
interface TextFormatUpdateEvent extends Event {
getTextFormats(): TextFormat[];
}
interface TextUpdateEvent extends Event {
readonly updateRangeStart: number;
readonly updateRangeEnd: number;
readonly text: string;
readonly selectionStart: number;
readonly selectionEnd: number;
}
interface EditContextEventMap {
characterboundsupdate: CharacterBoundsUpdateEvent;
compositionstart: Event;
compositionend: Event;
textformatupdate: TextFormatUpdateEvent;
textupdate: TextUpdateEvent;
}
interface EditContext extends EventTarget {
readonly text: string;
readonly selectionStart: number;
readonly selectionEnd: number;
readonly characterBoundsRangeStart: number;
oncharacterboundsupdate?: (event: CharacterBoundsUpdateEvent) => any;
oncompositionstart?: (event: Event) => any;
oncompositionend?: (event: Event) => any;
ontextformatupdate?: (event: TextFormatUpdateEvent) => any;
ontextupdate?: (event: TextUpdateEvent) => any;
attachedElements(): [HTMLElement];
characterBounds(): DOMRect[];
updateText(rangeStart: number, rangeEnd: number, text: string): void;
updateSelection(start: number, end: number): void;
updateControlBounds(controlBounds: DOMRect): void;
updateSelectionBounds(selectionBounds: DOMRect): void;
updateCharacterBounds(rangeStart: number, characterBounds: DOMRect[]): void;
addEventListener<K extends keyof EditContextEventMap>(type: K, listener: (this: EditContext, ev: EditContextEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
}
interface EditContextConstructor {
new(): EditContext;
new(options: Partial<{ text: string, selectionStart: number, selectionEnd: number }>): EditContext;
readonly prototype: EditContext;
}
var EditContext: EditContextConstructor;
}
const offsetOf = (node: Node, nodes: Node[]) => nodes.slice(0, nodes.indexOf(node)).reduce((t, n) => t + n.textContent!.length, 0);
const toRange = (root: Node, start: number, end: number): Range => {
let index = 0;
let startNode = null;
let endNode = null;
for (const node of getTextNodes(root)) {
const length = node.textContent!.length;
if (index <= start && (index + length) >= start) {
startNode = [node, Math.abs(end - index)] as const;
}
if (index <= end && (index + length) >= end) {
endNode = [node, Math.abs(end - index)] as const;
}
if (startNode !== null && endNode !== null) {
break;
}
index += length;
}
const range = new Range();
if (startNode !== null) {
range.setStart(...startNode);
}
if (endNode !== null) {
range.setEnd(...endNode);
}
return range;
};
const toIndices = (node: Node, range: Range): [number, number] => {
const nodes = getTextNodes(node);
const start = offsetOf(range.startContainer, nodes) + range.startOffset;
const end = offsetOf(range.endContainer, nodes) + range.endOffset;
return [start, end];
};

View file

@ -0,0 +1,3 @@
export { createEditContext } from './context';

View file

@ -0,0 +1,194 @@
const WHITESPACE = [" ", "\n", "\t"];
function getOpenTagName(htmlString: string, pos: number) {
let tagName = "";
let char = htmlString.charAt(pos);
while (char !== ">" && char !== " " && char !== "/" && char !== "") {
tagName += char;
char = htmlString.charAt(++pos);
}
return tagName;
}
function getCloseTagName(htmlString: string, pos: number) {
let tagName = "";
let char = htmlString.charAt(pos);
while (char !== ">" && char !== "") {
tagName += char;
char = htmlString.charAt(++pos);
}
return tagName;
}
function getWhiteSpace(htmlString: string, pos: number) {
let whitespace = "";
let char = htmlString.charAt(pos);
while (WHITESPACE.includes(char) && char !== "") {
whitespace += char;
char = htmlString.charAt(++pos);
}
return whitespace;
}
function getAttributeName(htmlString: string, pos: number) {
let attributeName = "";
let char = htmlString.charAt(pos);
while (char !== "=" && char !== " " && char !== ">" && char !== "") {
attributeName += char;
char = htmlString.charAt(++pos);
}
return attributeName;
}
function getAttributeValue(htmlString: string, pos: number, quote: string) {
let attributeValue = "";
let char = htmlString.charAt(pos);
const isAtEnd = (c) => {
if (quote) {
return c === quote || c === "";
}
return c === " " || c === ">" || c === "/" || c === "";
};
while (!isAtEnd(char)) {
attributeValue += char;
char = htmlString.charAt(++pos);
}
return attributeValue;
}
function getText(htmlString: string, pos: number) {
let text = "";
let char = htmlString.charAt(pos);
while (char !== "<" && char !== "") {
text += char;
char = htmlString.charAt(++pos);
}
return text;
}
export function tokenizeHTML(htmlString: string) {
let pos = 0;
let isInTag = false;
let isInAttribute = false;
let isAfterAttributeEqual = false;
const tokens = [];
while (pos < htmlString.length) {
const char = htmlString.charAt(pos);
const nextChar = htmlString.charAt(pos + 1);
if (char === "<" && nextChar !== "/" && !isInTag && !isInAttribute) {
isInTag = true;
tokens.push({ type: "openTagStart", value: "<", pos });
pos++;
const tagName = getOpenTagName(htmlString, pos);
tokens.push({ type: "tagName", value: tagName, pos });
pos += tagName.length;
continue;
}
if (WHITESPACE.includes(char) && isInTag) {
const whitespace = getWhiteSpace(htmlString, pos);
tokens.push({ type: "whitespace", value: whitespace, pos });
pos += whitespace.length;
isInAttribute = false;
continue;
}
if (char === ">" && isInTag && !isInAttribute) {
isInTag = false;
tokens.push({ type: "openTagEnd", value: ">", pos });
pos++;
continue;
}
if (isInTag && !isInAttribute && char === "/" && nextChar === ">") {
isInTag = false;
tokens.push({ type: "selfClose", value: "/>", pos });
pos += 2;
continue;
}
if (isInTag && !isInAttribute) {
isInAttribute = true;
const attributeName = getAttributeName(htmlString, pos);
tokens.push({ type: "attributeName", value: attributeName, pos });
pos += attributeName.length;
if (htmlString.charAt(pos) !== "=" && htmlString.charAt(pos) !== "'" && htmlString.charAt(pos) !== '"') {
isInAttribute = false;
}
continue;
}
if (char === "=" && isInAttribute && isInTag) {
isAfterAttributeEqual = true;
tokens.push({ type: "equal", value: "=", pos });
pos++;
continue;
}
if (isAfterAttributeEqual && isInAttribute && isInTag) {
const hasQuote = char === "'" || char === '"';
const quote = hasQuote ? char : "";
if (hasQuote) {
tokens.push({ type: "quoteStart", value: quote, pos });
pos++;
}
const attributeValue = getAttributeValue(htmlString, pos, quote);
tokens.push({ type: "attributeValue", value: attributeValue, pos });
pos += attributeValue.length;
if (hasQuote && htmlString.charAt(pos) === quote) {
tokens.push({ type: "quoteEnd", value: quote, pos });
pos++;
}
isInAttribute = false;
isAfterAttributeEqual = false;
continue;
}
if (!isInTag && char === "<" && nextChar === "/") {
tokens.push({ type: "closeTagStart", value: "</", pos });
pos += 2;
const tagName = getCloseTagName(htmlString, pos);
tokens.push({ type: "tagName", value: tagName, pos });
pos += tagName.length;
if (htmlString.charAt(pos) === ">") {
tokens.push({ type: "closeTagEnd", value: ">", pos });
pos++;
}
continue;
}
if (!isInTag) {
const text = getText(htmlString, pos);
tokens.push({ type: "text", value: text, pos });
pos += text.length;
continue;
}
}
return tokens;
}

View file

@ -1,10 +1,9 @@
import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show, untrack } from "solid-js";
import { decode, Mutation } from "~/utilities";
import { Mutation } from "~/utilities";
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
import { SelectionItem } from "../selectable";
import { useI18n } from "../i18n";
import { debounce } from "@solid-primitives/scheduled";
import css from "./grid.module.css"
import { Textarea } from "~/components/textarea";

View file

@ -1,4 +1,4 @@
import { Accessor, children, Component, createContext, createEffect, createMemo, createResource, createSignal, For, InitializedResource, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js";
import { Accessor, Component, createContext, createMemo, createResource, createSignal, For, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js";
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
import { SelectionProvider, selectable } from "~/features/selectable";
import { debounce } from "@solid-primitives/scheduled";

View file

@ -1,15 +1,17 @@
import { createEffect, onMount } from "solid-js";
import { Accessor, createEffect } from "solid-js";
import { createStore } from "solid-js/store";
import { unified } from 'unified'
import { Text, Root } from 'hast'
import { visit } from "unist-util-visit";
import { decode } from "~/utilities";
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkStringify from 'remark-stringify'
import rehypeParse from 'rehype-parse'
import rehypeDomParse from 'rehype-dom-parse'
import rehypeRemark from 'rehype-remark'
import rehypeStringify from 'rehype-stringify'
import type { Text, Root } from 'hast'
import { isServer } from "solid-js/web";
interface SourceStore {
in: string;
@ -34,29 +36,12 @@ export interface Source {
// TODO :: make this configurable, right now we can only do markdown <--> html.
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
const outToInProcessor = unified().use(isServer ? rehypeParse : rehypeDomParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(initalValue: string): Source {
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
const out = String(inToOutProcessor.stringify(ast));
const plain = String(unified().use(plainTextStringify).stringify(ast));
export function createSource(value: Accessor<string>): Source {
const [store, setStore] = createStore<SourceStore>({ in: '', out: '', plain: '', query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
const [store, setStore] = createStore<SourceStore>({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
createEffect(() => {
const value = store.plain;
setStore('metadata', {
spellingErrors: spellChecker(value, ''),
grammarErrors: grammarChecker(value, ''),
});
});
createEffect(() => {
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
});
return {
const src: Source = {
get in() {
return store.in;
},
@ -102,6 +87,26 @@ export function createSource(initalValue: string): Source {
return store.metadata.queryResults;
},
};
createEffect(() => {
src.in = value();
});
src.in = value();
createEffect(() => {
const value = store.plain;
setStore('metadata', {
spellingErrors: spellChecker(value, ''),
grammarErrors: grammarChecker(value, ''),
});
});
createEffect(() => {
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
});
return src;
}
function plainTextStringify() {