Skip to content

Commit 82ee799

Browse files
committed
Restore selection
1 parent 39a3415 commit 82ee799

File tree

1 file changed

+149
-16
lines changed

1 file changed

+149
-16
lines changed

packages/rendermime/src/renderers.ts

+149-16
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,16 @@ function nativeSanitize(source: string): string {
809809
return el.innerHTML;
810810
}
811811

812+
/**
813+
* Enum used exclusively for static analysis to ensure that each
814+
* branch in `renderFrame` leads to explicit rendering decision.
815+
*/
816+
enum RenderingResult {
817+
stop,
818+
delay,
819+
continue
820+
}
821+
812822
/**
813823
* Render the textual representation into a host node.
814824
*
@@ -835,7 +845,7 @@ function renderTextual(
835845
let fullPreTextContent: string | null = null;
836846
let pre: HTMLPreElement;
837847

838-
let isVisible = true;
848+
let isVisible = false;
839849

840850
// We will use the observer to pause rendering if the element
841851
// is not visible; this is helpful when opening a notebook
@@ -844,27 +854,51 @@ function renderTextual(
844854
if (typeof IntersectionObserver !== 'undefined') {
845855
observer = new IntersectionObserver(
846856
entries => {
847-
isVisible = entries[0].isIntersecting;
857+
for (const entry of entries) {
858+
isVisible = entry.isIntersecting;
859+
if (isVisible) {
860+
wasEverVisible = true;
861+
}
862+
}
848863
},
849864
{ threshold: 0 }
850865
);
851866
observer.observe(host);
852867
}
868+
let wasEverVisible = false;
853869

854870
const stopRendering = () => {
855871
// Remove the host from rendering queue
856872
Private.removeFromQueue(host);
857873
// Disconnect the intersection observer.
858874
observer?.disconnect();
875+
return RenderingResult.stop;
859876
};
860877

861-
const renderFrame = (timestamp: number) => {
862-
if (!host.isConnected) {
878+
const continueRendering = () => {
879+
iteration += 1;
880+
Private.scheduleRendering(host, renderFrame);
881+
return RenderingResult.continue;
882+
};
883+
884+
const delayRendering = () => {
885+
Private.scheduleRendering(host, renderFrame);
886+
return RenderingResult.delay;
887+
};
888+
889+
const renderFrame = (timestamp: number): RenderingResult => {
890+
if (!host.isConnected && wasEverVisible) {
863891
// Abort rendering if host is no longer in DOM; note, that even in
864892
// full windowing notebook mode the output nodes are never removed,
865893
// but instead the cell gets hidden (usually with `display: none`
866894
// or in case of the active cell - opacity tricks).
867-
return stopRendering();
895+
if (!wasEverVisible) {
896+
// If the host was never visible, it means it was not yet
897+
// attached when loading notebook for the first time.
898+
return delayRendering();
899+
} else {
900+
return stopRendering();
901+
}
868902
}
869903

870904
// Delay rendering of this output if the output is not visible due to
@@ -874,8 +908,7 @@ function renderTextual(
874908
// before we appended the new nodes from the stream, which leads to layout
875909
// trashing. Instead we use intersection observer
876910
if (!isVisible || !Private.canRenderInFrame(timestamp, host)) {
877-
Private.scheduleRendering(host, renderFrame);
878-
return;
911+
return delayRendering();
879912
}
880913

881914
const start = performance.now();
@@ -992,8 +1025,7 @@ function renderTextual(
9921025
// - new stream part was received (and new request sent),
9931026
// - maximum iterations limit was exceeded,
9941027
if (moreWorkToBeDone && !newRequest && iteration < maxIterations) {
995-
iteration += 1;
996-
Private.scheduleRendering(host, renderFrame);
1028+
return continueRendering();
9971029
} else {
9981030
return stopRendering();
9991031
}
@@ -1002,9 +1034,112 @@ function renderTextual(
10021034
Private.scheduleRendering(host, renderFrame);
10031035
}
10041036

1037+
interface ISelectionOffsets {
1038+
processedCharacters: number;
1039+
anchor: number | null;
1040+
focus: number | null;
1041+
}
1042+
1043+
function computeSelectionCharacterOffset(
1044+
root: Node,
1045+
selection: Selection
1046+
): ISelectionOffsets {
1047+
let anchor: number | null = null;
1048+
let focus: number | null = null;
1049+
let offset = 0;
1050+
for (const node of [...root.childNodes]) {
1051+
if (node === selection.focusNode) {
1052+
focus = offset + selection.focusOffset;
1053+
}
1054+
if (node === selection.anchorNode) {
1055+
anchor = offset + selection.anchorOffset;
1056+
}
1057+
if (node.childNodes.length > 0) {
1058+
const result = computeSelectionCharacterOffset(node, selection);
1059+
if (result.anchor) {
1060+
anchor = offset + result.anchor;
1061+
}
1062+
if (result.focus) {
1063+
focus = offset + result.focus;
1064+
}
1065+
offset += result.processedCharacters;
1066+
} else {
1067+
offset += node.textContent!.length;
1068+
}
1069+
if (anchor && focus) {
1070+
break;
1071+
}
1072+
}
1073+
return {
1074+
processedCharacters: offset,
1075+
anchor,
1076+
focus
1077+
};
1078+
}
1079+
1080+
function findTextSelectionNode(
1081+
root: Node,
1082+
textOffset: number | null,
1083+
offset: number
1084+
) {
1085+
if (textOffset !== null) {
1086+
for (const node of [...root.childNodes]) {
1087+
// As much as possible avoid calling `textContent` here as it will cause layout invalidation
1088+
const nodeEnd =
1089+
node instanceof Text
1090+
? node.nodeValue!.length
1091+
: (node instanceof HTMLAnchorElement
1092+
? node.childNodes[0].nodeValue?.length ?? node.textContent?.length
1093+
: node.textContent?.length) ?? 0;
1094+
if (textOffset > offset && textOffset < offset + nodeEnd) {
1095+
if (node instanceof Text) {
1096+
return { node, positionOffset: textOffset - offset };
1097+
} else {
1098+
return findTextSelectionNode(node, textOffset, offset);
1099+
}
1100+
} else {
1101+
offset += nodeEnd;
1102+
}
1103+
}
1104+
}
1105+
return {
1106+
node: null,
1107+
positionOffset: null
1108+
};
1109+
}
1110+
1111+
function selectByOffsets(
1112+
root: Node,
1113+
selection: Selection,
1114+
offsets: ISelectionOffsets
1115+
) {
1116+
const { node: focusNode, positionOffset: focusOffset } =
1117+
findTextSelectionNode(root, offsets.focus, 0);
1118+
const { node: anchorNode, positionOffset: anchorOffset } =
1119+
findTextSelectionNode(root, offsets.anchor, 0);
1120+
if (
1121+
anchorNode &&
1122+
focusNode &&
1123+
anchorOffset !== null &&
1124+
focusOffset !== null
1125+
) {
1126+
selection.setBaseAndExtent(
1127+
anchorNode,
1128+
anchorOffset,
1129+
focusNode,
1130+
focusOffset
1131+
);
1132+
}
1133+
}
1134+
10051135
function replaceChangedNodes(host: HTMLElement, node: HTMLPreElement) {
10061136
const result = checkChangedNodes(host, node);
1007-
// TODO: preserve selection
1137+
const selection = window.getSelection();
1138+
const hasSelection = selection && selection.containsNode(host, true);
1139+
const selectionOffsets = hasSelection
1140+
? computeSelectionCharacterOffset(host, selection)
1141+
: null;
1142+
const pre = result ? result.parent : node;
10081143
if (result) {
10091144
for (const element of result.toDelete) {
10101145
result.parent.removeChild(element);
@@ -1013,6 +1148,10 @@ function replaceChangedNodes(host: HTMLElement, node: HTMLPreElement) {
10131148
} else {
10141149
host.replaceChildren(node);
10151150
}
1151+
// Restore selection - if there is a meaningful one.
1152+
if (selection && selectionOffsets) {
1153+
selectByOffsets(pre, selection, selectionOffsets);
1154+
}
10161155
}
10171156

10181157
/**
@@ -1404,12 +1543,6 @@ namespace Private {
14041543
if (!renderQueue.includes(host)) {
14051544
renderQueue.push(host);
14061545
}
1407-
// TODO - instead of constantly calling rAF (and cancelling it)
1408-
// call it once and update arguments if new chunk is streamed;
1409-
// this is because there is
1410-
// an overhead associated (0.6s out of 10s so not terrible, but noticeable);
1411-
// instead we could pass arguments via
1412-
// a weakMap or similar.
14131546
const thisRequest = window.requestAnimationFrame(render);
14141547
frameRequests.set(host, thisRequest);
14151548
}

0 commit comments

Comments
 (0)