diff --git a/packages/carta-md/package.json b/packages/carta-md/package.json index 23248b1b..2e6166c4 100644 --- a/packages/carta-md/package.json +++ b/packages/carta-md/package.json @@ -28,6 +28,7 @@ "@sveltejs/kit": "^2.5.4", "@sveltejs/package": "^2.3.0", "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@types/diff": "^5.2.1", "svelte-check": "^3.6.7", "tslib": "^2.4.1", "typescript": "^5.1.6", @@ -35,6 +36,7 @@ }, "type": "module", "dependencies": { + "diff": "^5.2.0", "esm-env": "^1.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", diff --git a/packages/carta-md/src/lib/internal/components/Input.svelte b/packages/carta-md/src/lib/internal/components/Input.svelte index 49a2048d..47b63d0b 100644 --- a/packages/carta-md/src/lib/internal/components/Input.svelte +++ b/packages/carta-md/src/lib/internal/components/Input.svelte @@ -10,6 +10,7 @@ import type { TextAreaProps } from '../textarea-props'; import { debounce } from '../utils'; import { BROWSER } from 'esm-env'; + import { removeTemporaryNodes, speculativeHighlightUpdate } from '../speculative'; /** * The Carta instance to use. @@ -33,9 +34,10 @@ export let props: TextAreaProps = {}; let textarea: HTMLTextAreaElement; - let highlighElem: HTMLDivElement; + let highlightElem: HTMLDivElement; let highlighted = value; let mounted = false; + let prevValue = value; /** * Manually resize the textarea to fit the content, so that it @@ -43,7 +45,7 @@ */ export const resize = () => { if (!mounted || !textarea) return; - textarea.style.height = highlighElem.scrollHeight + 'px'; + textarea.style.height = highlightElem.scrollHeight + 'px'; textarea.scrollTop = 0; }; @@ -59,7 +61,7 @@ * Highlight the text in the textarea. * @param text The text to highlight. */ - const highlight = async (text: string) => { + const highlight = debounce(async (text: string) => { const highlighter = await carta.highlighter(); if (!highlighter) return; let html: string; @@ -81,12 +83,16 @@ }); } + removeTemporaryNodes(highlightElem); + if (carta.sanitizer) { highlighted = carta.sanitizer(html); } else { highlighted = html; } - }; + + requestAnimationFrame(resize); + }, 250); /** * Highlight the nested languages in the markdown, loading the necessary @@ -104,7 +110,12 @@ }, 300); const onValueChange = (value: string) => { - highlight(value).then(resize); + if (highlightElem) { + speculativeHighlightUpdate(highlightElem, prevValue, value); + requestAnimationFrame(resize); + } + + highlight(value); highlightNestedLanguages(value); }; @@ -118,7 +129,6 @@ onMount(() => { carta.$setInput(textarea, elem, () => { value = textarea.value; - highlight(value); }); }); @@ -140,7 +150,7 @@ class="carta-highlight carta-font-code" tabindex="-1" aria-hidden="true" - bind:this={highlighElem} + bind:this={highlightElem} > {@html highlighted} @@ -158,6 +168,7 @@ bind:value bind:this={textarea} on:scroll={() => (textarea.scrollTop = 0)} + on:keydown={() => (prevValue = value)} /> diff --git a/packages/carta-md/src/lib/internal/speculative.ts b/packages/carta-md/src/lib/internal/speculative.ts new file mode 100644 index 00000000..44c71383 --- /dev/null +++ b/packages/carta-md/src/lib/internal/speculative.ts @@ -0,0 +1,137 @@ +import { diffChars, type Change } from 'diff'; + +/** + * Temporary updates the highlight overlay to reflect the changes between two text strings, + * waiting for the actual update to be applied. This way, the user can immediately see the changes, + * without a delay of around ~100ms. This makes the UI feel more responsive. + * @param container The highlight overlay container. + * @param from Previous text. + * @param to Current text. + */ +export function speculativeHighlightUpdate(container: HTMLDivElement, from: string, to: string) { + const diff = diffChars(from.replaceAll('\r\n', '\n'), to.replaceAll('\r\n', '\n')); + + const textNodes = textNodesUnder(container); + + if (textNodes.length == 0) { + textNodes.push(createTemporaryNode(container)); + } + + let currentNodeIdx = 0; + let currentNodeCharIdx = 0; // char offset inside current node + + const advance = (count: number) => { + let steps = 0; + while (steps < count && currentNodeIdx < textNodes.length) { + const node = textNodes[currentNodeIdx]; + const text = node.textContent ?? ''; + const availableNodeChars = text.length - currentNodeCharIdx; + + const stepsTaken = Math.min(availableNodeChars, count - steps); + + steps += stepsTaken; + currentNodeCharIdx += stepsTaken; + if (stepsTaken == availableNodeChars) { + currentNodeIdx++; + currentNodeCharIdx = 0; + } + } + }; + + const unchangedCallback = (change: Change) => { + advance(change.value.length); + }; + const addedCallback = (change: Change) => { + const node = textNodes[currentNodeIdx] ?? createTemporaryNode(container); + + const text = node.textContent ?? ''; + const pre = text.slice(0, currentNodeCharIdx); + const post = text.slice(currentNodeCharIdx); + + node.textContent = pre + change.value + post; + advance(change.value.length); + }; + const removedCallback = (change: Change) => { + // Use the Selection object to delete removed text + const startNodeIdx = currentNodeIdx; + const startNodeCharIdx = currentNodeCharIdx; + advance(change.value.length); + const endNodeIdx = currentNodeIdx; + let endNodeCharIdx = currentNodeCharIdx; + + const startNode = textNodes[startNodeIdx]; + let endNode = textNodes[endNodeIdx]; + + if (!endNode) { + // Remove everything + endNode = textNodes.at(-1)!; + endNodeCharIdx = endNode.textContent?.length ?? 0; + } + + const range = new Range(); + range.setStart(startNode, startNodeCharIdx); + range.setEnd(endNode, endNodeCharIdx); + + const selection = window.getSelection(); + if (!selection) return; + + const initialRanges = new Array(selection.rangeCount).map((_, i) => selection.getRangeAt(i)); + selection.removeAllRanges(); + + selection.addRange(range); + selection.deleteFromDocument(); + + initialRanges.forEach(selection.addRange); + + // Go back to start + currentNodeIdx = startNodeIdx; + currentNodeCharIdx = startNodeCharIdx; + }; + + for (const change of diff) { + if (change.added) { + addedCallback(change); + } else if (change.removed) { + removedCallback(change); + } else { + unchangedCallback(change); + } + } +} + +/** + * Finds all text nodes under a given element. + * @param elem The element to search for text nodes. + * @returns The text nodes under the given element. + */ +function textNodesUnder(elem: HTMLElement) { + const children = []; + const walker = document.createTreeWalker(elem, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + children.push(walker.currentNode); + } + return children; +} + +/** + * Creates a new text node appended to the last line of the container. + * @param container The highlight overlay container. + * @returns A new text node appended to the last line of the container. + */ +export function createTemporaryNode(container: HTMLDivElement) { + const span = Array.from(container.querySelectorAll('.line')).at(-1)!; + + const node = document.createTextNode(''); + span.appendChild(node); + + span.setAttribute('data-temp-node', 'true'); + return node; +} + +/** + * Removes all temporary nodes from the highlight overlay container. + * @param container The highlight overlay container. + */ +export function removeTemporaryNodes(container: HTMLDivElement) { + container.querySelectorAll('[data-temp-node]').forEach((node) => node.remove()); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e988de6..00bede1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: packages/carta-md: dependencies: + diff: + specifier: ^5.2.0 + version: 5.2.0 esm-env: specifier: ^1.0.0 version: 1.0.0 @@ -196,6 +199,9 @@ importers: '@sveltejs/vite-plugin-svelte': specifier: ^3.0.2 version: 3.0.2(svelte@4.2.12)(vite@5.1.6(@types/node@20.5.1)(sass@1.69.5)) + '@types/diff': + specifier: ^5.2.1 + version: 5.2.1 svelte-check: specifier: ^3.6.7 version: 3.6.7(postcss-load-config@4.0.1(postcss@8.4.36)(ts-node@10.9.1(@types/node@18.16.3)(typescript@5.3.3)))(postcss@8.4.36)(sass@1.69.5)(svelte@4.2.12) @@ -1125,6 +1131,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff@5.2.1': + resolution: {integrity: sha512-uxpcuwWJGhe2AR1g8hD9F5OYGCqjqWnBUQFD8gMZsDbv8oPHzxJF6iMO6n8Tk0AdzlxoaaoQhOYlIg/PukVU8g==} + '@types/estree@1.0.1': resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} @@ -1862,6 +1871,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5359,6 +5372,8 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/diff@5.2.1': {} + '@types/estree@1.0.1': {} '@types/estree@1.0.5': {} @@ -6143,6 +6158,8 @@ snapshots: diff@4.0.2: optional: true + diff@5.2.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0