@@ -809,6 +809,16 @@ function nativeSanitize(source: string): string {
809
809
return el . innerHTML ;
810
810
}
811
811
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
+
812
822
/**
813
823
* Render the textual representation into a host node.
814
824
*
@@ -835,7 +845,7 @@ function renderTextual(
835
845
let fullPreTextContent : string | null = null ;
836
846
let pre : HTMLPreElement ;
837
847
838
- let isVisible = true ;
848
+ let isVisible = false ;
839
849
840
850
// We will use the observer to pause rendering if the element
841
851
// is not visible; this is helpful when opening a notebook
@@ -844,27 +854,51 @@ function renderTextual(
844
854
if ( typeof IntersectionObserver !== 'undefined' ) {
845
855
observer = new IntersectionObserver (
846
856
entries => {
847
- isVisible = entries [ 0 ] . isIntersecting ;
857
+ for ( const entry of entries ) {
858
+ isVisible = entry . isIntersecting ;
859
+ if ( isVisible ) {
860
+ wasEverVisible = true ;
861
+ }
862
+ }
848
863
} ,
849
864
{ threshold : 0 }
850
865
) ;
851
866
observer . observe ( host ) ;
852
867
}
868
+ let wasEverVisible = false ;
853
869
854
870
const stopRendering = ( ) => {
855
871
// Remove the host from rendering queue
856
872
Private . removeFromQueue ( host ) ;
857
873
// Disconnect the intersection observer.
858
874
observer ?. disconnect ( ) ;
875
+ return RenderingResult . stop ;
859
876
} ;
860
877
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 ) {
863
891
// Abort rendering if host is no longer in DOM; note, that even in
864
892
// full windowing notebook mode the output nodes are never removed,
865
893
// but instead the cell gets hidden (usually with `display: none`
866
894
// 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
+ }
868
902
}
869
903
870
904
// Delay rendering of this output if the output is not visible due to
@@ -874,8 +908,7 @@ function renderTextual(
874
908
// before we appended the new nodes from the stream, which leads to layout
875
909
// trashing. Instead we use intersection observer
876
910
if ( ! isVisible || ! Private . canRenderInFrame ( timestamp , host ) ) {
877
- Private . scheduleRendering ( host , renderFrame ) ;
878
- return ;
911
+ return delayRendering ( ) ;
879
912
}
880
913
881
914
const start = performance . now ( ) ;
@@ -992,8 +1025,7 @@ function renderTextual(
992
1025
// - new stream part was received (and new request sent),
993
1026
// - maximum iterations limit was exceeded,
994
1027
if ( moreWorkToBeDone && ! newRequest && iteration < maxIterations ) {
995
- iteration += 1 ;
996
- Private . scheduleRendering ( host , renderFrame ) ;
1028
+ return continueRendering ( ) ;
997
1029
} else {
998
1030
return stopRendering ( ) ;
999
1031
}
@@ -1002,9 +1034,112 @@ function renderTextual(
1002
1034
Private . scheduleRendering ( host , renderFrame ) ;
1003
1035
}
1004
1036
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
+
1005
1135
function replaceChangedNodes ( host : HTMLElement , node : HTMLPreElement ) {
1006
1136
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 ;
1008
1143
if ( result ) {
1009
1144
for ( const element of result . toDelete ) {
1010
1145
result . parent . removeChild ( element ) ;
@@ -1013,6 +1148,10 @@ function replaceChangedNodes(host: HTMLElement, node: HTMLPreElement) {
1013
1148
} else {
1014
1149
host . replaceChildren ( node ) ;
1015
1150
}
1151
+ // Restore selection - if there is a meaningful one.
1152
+ if ( selection && selectionOffsets ) {
1153
+ selectByOffsets ( pre , selection , selectionOffsets ) ;
1154
+ }
1016
1155
}
1017
1156
1018
1157
/**
@@ -1404,12 +1543,6 @@ namespace Private {
1404
1543
if ( ! renderQueue . includes ( host ) ) {
1405
1544
renderQueue . push ( host ) ;
1406
1545
}
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.
1413
1546
const thisRequest = window . requestAnimationFrame ( render ) ;
1414
1547
frameRequests . set ( host , thisRequest ) ;
1415
1548
}
0 commit comments