Skip to content

Commit

Permalink
perf(MarkdownEditor): speculative highlight updates
Browse files Browse the repository at this point in the history
  • Loading branch information
BearToCode committed Jul 17, 2024
1 parent 8f8d3f1 commit 9f717c5
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 7 deletions.
2 changes: 2 additions & 0 deletions packages/carta-md/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
"@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",
"vite": "^5.1.6"
},
"type": "module",
"dependencies": {
"diff": "^5.2.0",
"esm-env": "^1.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
Expand Down
25 changes: 18 additions & 7 deletions packages/carta-md/src/lib/internal/components/Input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,17 +34,18 @@
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
* always perfectly overlaps the highlighting overlay.
*/
export const resize = () => {
if (!mounted || !textarea) return;
textarea.style.height = highlighElem.scrollHeight + 'px';
textarea.style.height = highlightElem.scrollHeight + 'px';
textarea.scrollTop = 0;
};
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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);
};
Expand All @@ -118,7 +129,6 @@
onMount(() => {
carta.$setInput(textarea, elem, () => {
value = textarea.value;
highlight(value);
});
});
</script>
Expand All @@ -140,7 +150,7 @@
class="carta-highlight carta-font-code"
tabindex="-1"
aria-hidden="true"
bind:this={highlighElem}
bind:this={highlightElem}
>
<!-- eslint-disable-line svelte/no-at-html-tags -->{@html highlighted}
</div>
Expand All @@ -158,6 +168,7 @@
bind:value
bind:this={textarea}
on:scroll={() => (textarea.scrollTop = 0)}
on:keydown={() => (prevValue = value)}
/>
</div>

Expand Down
137 changes: 137 additions & 0 deletions packages/carta-md/src/lib/internal/speculative.ts
Original file line number Diff line number Diff line change
@@ -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());
}
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 9f717c5

Please sign in to comment.