fix terminal resize flicker during pane drag (#962)
This commit is contained in:
parent
91b7f04594
commit
c07db38dfe
|
|
@ -1,65 +0,0 @@
|
|||
diff --git a/lib/addon-fit.js b/lib/addon-fit.js
|
||||
index 9b25be35bbc0a6826b769e9bbc09f0d8ea1816e4..8c11959d05e8d51350ee79b7be415fa85e0b1468 100644
|
||||
--- a/lib/addon-fit.js
|
||||
+++ b/lib/addon-fit.js
|
||||
@@ -1,2 +1,2 @@
|
||||
-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;const i=this._terminal.cols!==e.cols,r2=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r2)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/lib/addon-fit.mjs b/lib/addon-fit.mjs
|
||||
index 24d4f61877d369edb7f2773570575a1c10edf832..a6a841b9fb2e03082f417bde57a8e74376e4c22f 100644
|
||||
--- a/lib/addon-fit.mjs
|
||||
+++ b/lib/addon-fit.mjs
|
||||
@@ -14,5 +14,5 @@
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
-var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core;(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon};
|
||||
+var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core,i=this._terminal.cols!==e.cols,r=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon};
|
||||
//# sourceMappingURL=addon-fit.mjs.map
|
||||
diff --git a/src/FitAddon.ts b/src/FitAddon.ts
|
||||
index a282ed3f8c607f5d073af3d0f1585e8cbc7b9f63..63796e8cc23f5d3dff61f322ee9a16747da51c4c 100644
|
||||
--- a/src/FitAddon.ts
|
||||
+++ b/src/FitAddon.ts
|
||||
@@ -41,10 +41,37 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
// TODO: Remove reliance on private API
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
- // Force a full render
|
||||
- if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
|
||||
- core._renderService.clear();
|
||||
+ const colsChanged = this._terminal.cols !== dims.cols;
|
||||
+ const rowsChanged = this._terminal.rows !== dims.rows;
|
||||
+
|
||||
+ // Capture viewport scroll position before resize so we can restore it
|
||||
+ // after xterm.js reflows the scrollback buffer. `baseY` is the number
|
||||
+ // of rows scrolled into the scrollback; `viewportY === baseY` means the
|
||||
+ // viewport is pinned to the bottom (the common interactive-shell case).
|
||||
+ const buf = this._terminal.buffer.active;
|
||||
+ const wasAtBottom = buf.viewportY >= buf.baseY;
|
||||
+
|
||||
+ // Always clear the renderer — even same-dimension refits may need a
|
||||
+ // repaint when the container's pixel size changed by less than one cell
|
||||
+ // (upstream fix: xtermjs/xterm.js#5777).
|
||||
+ core._renderService.clear();
|
||||
+
|
||||
+ if (colsChanged || rowsChanged) {
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
+
|
||||
+ // After reflow the viewport can land at a stale scroll offset — the
|
||||
+ // scrollback appears to vanish (blank rows at top, recent output at
|
||||
+ // bottom). Snap back to the bottom if the user was following output;
|
||||
+ // otherwise preserve their scroll-up position.
|
||||
+ if (wasAtBottom) {
|
||||
+ this._terminal.scrollToBottom();
|
||||
+ }
|
||||
}
|
||||
+
|
||||
+ // Always refresh the full viewport after clear() — even when cols/rows
|
||||
+ // did not change, the pixel-level container resize can leave the WebGL
|
||||
+ // or DOM renderer with blank content (e.g. when a new pane opens and the
|
||||
+ // container shrinks by less than one cell width).
|
||||
+ this._terminal.refresh(0, this._terminal.rows - 1);
|
||||
}
|
||||
|
||||
17
package.json
17
package.json
|
|
@ -65,14 +65,14 @@
|
|||
"@tiptap/pm": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-fit": "0.12.0-beta.198",
|
||||
"@xterm/addon-search": "0.17.0-beta.198",
|
||||
"@xterm/addon-serialize": "0.15.0-beta.198",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.198",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.198",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.197",
|
||||
"@xterm/headless": "6.1.0-beta.198",
|
||||
"@xterm/xterm": "6.1.0-beta.198",
|
||||
"agent-browser": "~0.24.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -151,7 +151,6 @@
|
|||
"node-pty"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@xterm/addon-fit@0.11.0": "config/patches/@xterm__addon-fit@0.11.0.patch",
|
||||
"node-pty@1.1.0": "patches/node-pty@1.1.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
pnpm-lock.yaml
107
pnpm-lock.yaml
|
|
@ -5,9 +5,6 @@ settings:
|
|||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
hash: 12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776
|
||||
path: config/patches/@xterm__addon-fit@0.11.0.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322
|
||||
path: patches/node-pty@1.1.0.patch
|
||||
|
|
@ -83,29 +80,29 @@ importers:
|
|||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776)
|
||||
specifier: 0.12.0-beta.198
|
||||
version: 0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/addon-search':
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
specifier: 0.17.0-beta.198
|
||||
version: 0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/addon-serialize':
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0
|
||||
specifier: 0.15.0-beta.198
|
||||
version: 0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
specifier: 0.10.0-beta.198
|
||||
version: 0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
specifier: 0.13.0-beta.198
|
||||
version: 0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
specifier: 0.20.0-beta.197
|
||||
version: 0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)
|
||||
'@xterm/headless':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
specifier: 6.1.0-beta.198
|
||||
version: 6.1.0-beta.198
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
specifier: 6.1.0-beta.198
|
||||
version: 6.1.0-beta.198
|
||||
agent-browser:
|
||||
specifier: ~0.24.1
|
||||
version: 0.24.1
|
||||
|
|
@ -2917,29 +2914,41 @@ packages:
|
|||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
'@xterm/addon-fit@0.12.0-beta.198':
|
||||
resolution: {integrity: sha512-1EZFzOaUjRnlo5p2diIV9+HHVtHqr0O7TEFCstSuFqV/5QaKkRiWiKaMfsPEg2pF5P7yBOASk73yUPII7oquaA==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-search@0.16.0':
|
||||
resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==}
|
||||
'@xterm/addon-search@0.17.0-beta.198':
|
||||
resolution: {integrity: sha512-loNSX0cB4P+9Sg13sgB/cQPPBgdOi1hxRyZ75Jj90+4iKe227mRmgzdqU3wxr10nyFWJWCpysCjVT+47tdC8mQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-serialize@0.14.0':
|
||||
resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==}
|
||||
'@xterm/addon-serialize@0.15.0-beta.198':
|
||||
resolution: {integrity: sha512-0yf13cLPaKX8nkgUqclcdbU5FexacA/xnvWzHzGFYTku0WH+SqygbQcDOcg4bTUGgCcvfZqntEb1NQpr+69KgQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-unicode11@0.9.0':
|
||||
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
|
||||
'@xterm/addon-unicode11@0.10.0-beta.198':
|
||||
resolution: {integrity: sha512-3itjZ5lTQ5tnDcpUAplX88BsTJTZOvEzX9wNTNMO2aPuctLavMNg2+pXU8KTkeRhUZnDyhx9Lsv81dDs94vWDQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-web-links@0.12.0':
|
||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||
'@xterm/addon-web-links@0.13.0-beta.198':
|
||||
resolution: {integrity: sha512-qA/B1tuNLpqlzscrAVFjLKjuH0CmKKyfbTn78Y3RLKs6H7xxdYSE6/DjJ1zg9C6aDtLwF9lsaOFpPSPf2AfpYQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-webgl@0.19.0':
|
||||
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||
'@xterm/addon-webgl@0.20.0-beta.197':
|
||||
resolution: {integrity: sha512-0o30FvLzfd9ODuB1gbpwYMGQHYiz0FebmVaMP0Idesw1sLIG3qc7aIIBQEUnWiBDsKbDHhaysW8WTxacg2AhaQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.198
|
||||
|
||||
'@xterm/headless@6.0.0':
|
||||
resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==}
|
||||
'@xterm/headless@6.1.0-beta.198':
|
||||
resolution: {integrity: sha512-pDS/I5HLGwwJf8sWZFaT+qbHxgFu0qdVQv5/XDzhNyKXMXQJ7cL3ZsJsIox5JxLo2ZuYLVVY8/lirHronhOSfA==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
'@xterm/xterm@6.1.0-beta.198':
|
||||
resolution: {integrity: sha512-udjhNFi4YfypbVBJV7gFNwQV3KziZFJdq+fg87WefGF5JkGbC+4LvIhEsHXR0Kb5Ez8ESxeqM7Yjvj+kw8zDAg==}
|
||||
|
||||
abbrev@3.0.1:
|
||||
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
||||
|
|
@ -8671,21 +8680,33 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
'@xterm/addon-fit@0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776)': {}
|
||||
'@xterm/addon-fit@0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-search@0.16.0': {}
|
||||
'@xterm/addon-search@0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-serialize@0.14.0': {}
|
||||
'@xterm/addon-serialize@0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-unicode11@0.9.0': {}
|
||||
'@xterm/addon-unicode11@0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-web-links@0.12.0': {}
|
||||
'@xterm/addon-web-links@0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/addon-webgl@0.19.0': {}
|
||||
'@xterm/addon-webgl@0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.198
|
||||
|
||||
'@xterm/headless@6.0.0': {}
|
||||
'@xterm/headless@6.1.0-beta.198': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
'@xterm/xterm@6.1.0-beta.198': {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,11 +94,6 @@ function attachDividerDrag(
|
|||
callbacks.lockDragScroll(nextEl)
|
||||
}
|
||||
|
||||
// Why: fitAddon.fit() triggers a full xterm.js reflow which can take
|
||||
// hundreds of ms with large scrollbacks. Gating behind rAF caps refit
|
||||
// to once per paint frame instead of once per pointer event (~250Hz).
|
||||
let refitRafId: number | null = null
|
||||
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
if (!dragging || !prevEl || !nextEl) {
|
||||
return
|
||||
|
|
@ -121,20 +116,11 @@ function attachDividerDrag(
|
|||
newPrev = totalSize - MIN_PANE_SIZE
|
||||
}
|
||||
|
||||
// Why: keep drag-time work to flex layout only; pane-local ResizeObservers
|
||||
// schedule the terminal fit on the next frame so the divider stays smooth.
|
||||
// Use flex-grow proportionally
|
||||
prevEl.style.flex = `${newPrev} 1 0%`
|
||||
nextEl.style.flex = `${newNext} 1 0%`
|
||||
|
||||
// Refit terminals in affected panes (throttled to one per animation frame)
|
||||
if (refitRafId === null) {
|
||||
const p = prevEl
|
||||
const n = nextEl
|
||||
refitRafId = requestAnimationFrame(() => {
|
||||
refitRafId = null
|
||||
callbacks.refitPanesUnder(p)
|
||||
callbacks.refitPanesUnder(n)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
|
|
@ -142,10 +128,6 @@ function attachDividerDrag(
|
|||
return
|
||||
}
|
||||
dragging = false
|
||||
if (refitRafId !== null) {
|
||||
cancelAnimationFrame(refitRafId)
|
||||
refitRafId = null
|
||||
}
|
||||
divider.releasePointerCapture(e.pointerId)
|
||||
divider.classList.remove('is-dragging')
|
||||
// Final refit at the exact drop position, then unlock drag scroll state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import {
|
||||
attachPaneFitResizeObserver,
|
||||
detachPaneFitResizeObserver
|
||||
} from './pane-fit-resize-observer'
|
||||
|
||||
type ResizeObserverCallbackLike = ConstructorParameters<typeof ResizeObserver>[0]
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallbackLike) {
|
||||
mockResizeObservers.push(this)
|
||||
}
|
||||
|
||||
trigger(): void {
|
||||
this.callback([], this as never)
|
||||
}
|
||||
}
|
||||
|
||||
let mockResizeObservers: MockResizeObserver[] = []
|
||||
let nextRafId = 1
|
||||
let pendingRafs = new Map<number, FrameRequestCallback>()
|
||||
|
||||
function flushAnimationFrames(timestamp = 16): void {
|
||||
const callbacks = Array.from(pendingRafs.entries())
|
||||
pendingRafs = new Map()
|
||||
for (const [, callback] of callbacks) {
|
||||
callback(timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
function createPane(): ManagedPaneInternal {
|
||||
return {
|
||||
id: 1,
|
||||
terminal: {
|
||||
cols: 79,
|
||||
rows: 24
|
||||
} as never,
|
||||
container: {} as never,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
gpuRenderingEnabled: true,
|
||||
fitAddon: {
|
||||
fit: vi.fn(),
|
||||
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 24 }))
|
||||
} as never,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
webglAddon: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: {
|
||||
wasAtBottom: true,
|
||||
firstVisibleLineContent: '',
|
||||
viewportY: 0,
|
||||
totalLines: 24
|
||||
} satisfies ScrollState,
|
||||
pendingDragScrollState: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('attachPaneFitResizeObserver', () => {
|
||||
beforeEach(() => {
|
||||
mockResizeObservers = []
|
||||
nextRafId = 1
|
||||
pendingRafs = new Map()
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver as never)
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextRafId++
|
||||
pendingRafs.set(id, callback)
|
||||
return id
|
||||
})
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
pendingRafs.delete(id)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('coalesces repeated observer callbacks into a single fit per frame', () => {
|
||||
const pane = createPane()
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
mockResizeObservers[0]?.trigger()
|
||||
mockResizeObservers[0]?.trigger()
|
||||
|
||||
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disconnects the observer and cancels any queued fit', () => {
|
||||
const pane = createPane()
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
mockResizeObservers[0]?.trigger()
|
||||
const scheduledRafId = pane.pendingObservedFitRafId
|
||||
|
||||
detachPaneFitResizeObserver(pane)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(mockResizeObservers[0]?.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(scheduledRafId)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(pane.pendingObservedFitRafId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { safeFit } from './pane-tree-ops'
|
||||
|
||||
export function attachPaneFitResizeObserver(pane: ManagedPaneInternal): void {
|
||||
detachPaneFitResizeObserver(pane)
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pane.pendingObservedFitRafId !== null) {
|
||||
return
|
||||
}
|
||||
// Why: keep xterm fit work off the divider pointermove hot path and let
|
||||
// the browser coalesce drag-driven size changes the same way Superset does.
|
||||
pane.pendingObservedFitRafId = requestAnimationFrame(() => {
|
||||
pane.pendingObservedFitRafId = null
|
||||
safeFit(pane)
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(pane.xtermContainer)
|
||||
pane.fitResizeObserver = observer
|
||||
}
|
||||
|
||||
export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void {
|
||||
pane.fitResizeObserver?.disconnect()
|
||||
pane.fitResizeObserver = null
|
||||
|
||||
if (pane.pendingObservedFitRafId !== null) {
|
||||
cancelAnimationFrame(pane.pendingObservedFitRafId)
|
||||
pane.pendingObservedFitRafId = null
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ import type { DragReorderState } from './pane-drag-reorder'
|
|||
import type { DragReorderCallbacks } from './pane-drag-reorder'
|
||||
import { attachPaneDrag } from './pane-drag-reorder'
|
||||
import { safeFit, captureScrollState, restoreScrollState } from './pane-tree-ops'
|
||||
import {
|
||||
attachPaneFitResizeObserver,
|
||||
detachPaneFitResizeObserver
|
||||
} from './pane-fit-resize-observer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane creation, terminal open/close, addon management
|
||||
|
|
@ -128,6 +132,8 @@ export function createPaneDOM(
|
|||
linkTooltip,
|
||||
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
|
||||
fitAddon,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
searchAddon,
|
||||
serializeAddon,
|
||||
unicode11Addon,
|
||||
|
|
@ -223,6 +229,8 @@ export function openTerminal(pane: ManagedPaneInternal): void {
|
|||
attachWebgl(pane)
|
||||
}
|
||||
|
||||
attachPaneFitResizeObserver(pane)
|
||||
|
||||
// Initial fit (deferred to ensure layout has settled)
|
||||
requestAnimationFrame(() => {
|
||||
safeFit(pane)
|
||||
|
|
@ -301,6 +309,7 @@ export function disposePane(
|
|||
pane: ManagedPaneInternal,
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): void {
|
||||
detachPaneFitResizeObserver(pane)
|
||||
if (pane.compositionHandler) {
|
||||
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
|
||||
pane.compositionHandler = null
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export type ManagedPaneInternal = {
|
|||
linkTooltip: HTMLElement
|
||||
gpuRenderingEnabled: boolean
|
||||
webglAddon: WebglAddon | null
|
||||
fitResizeObserver: ResizeObserver | null
|
||||
pendingObservedFitRafId: number | null
|
||||
serializeAddon: SerializeAddon
|
||||
unicode11Addon: Unicode11Addon
|
||||
webLinksAddon: WebLinksAddon
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { safeFit } from './pane-tree-ops'
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
|
||||
function createPane({
|
||||
proposedCols,
|
||||
proposedRows,
|
||||
terminalCols,
|
||||
terminalRows
|
||||
}: {
|
||||
proposedCols: number
|
||||
proposedRows: number
|
||||
terminalCols: number
|
||||
terminalRows: number
|
||||
}): ManagedPaneInternal {
|
||||
const fit = vi.fn()
|
||||
const proposeDimensions = vi.fn(() => ({ cols: proposedCols, rows: proposedRows }))
|
||||
const terminal = {
|
||||
cols: terminalCols,
|
||||
rows: terminalRows,
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
baseY: 0,
|
||||
getLine: vi.fn(() => ({ translateToString: () => '' }))
|
||||
}
|
||||
},
|
||||
scrollToBottom: vi.fn(),
|
||||
scrollToLine: vi.fn(),
|
||||
scrollLines: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
id: 1,
|
||||
terminal: terminal as never,
|
||||
container: {} as never,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
gpuRenderingEnabled: true,
|
||||
fitAddon: {
|
||||
fit,
|
||||
proposeDimensions
|
||||
} as never,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
webglAddon: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
pendingDragScrollState: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('safeFit', () => {
|
||||
it('skips drag-frame refits when the pane grid dimensions did not change', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 120,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
|
||||
safeFit(pane)
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still refits when the proposed grid dimensions changed', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
|
||||
safeFit(pane)
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still refits when a split-scroll lock is active and the grid changed', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
pane.pendingSplitScrollState = {
|
||||
wasAtBottom: true,
|
||||
firstVisibleLineContent: '',
|
||||
viewportY: 0,
|
||||
totalLines: 32
|
||||
} satisfies ScrollState
|
||||
|
||||
safeFit(pane)
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -18,8 +18,23 @@ type TreeOpsCallbacks = {
|
|||
onLayoutChanged?: () => void
|
||||
}
|
||||
|
||||
function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: number } | null {
|
||||
try {
|
||||
return pane.fitAddon.proposeDimensions() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFit(pane: ManagedPaneInternal): void {
|
||||
try {
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
|
||||
// Why: divider drags fire refits every frame, but most frames do not
|
||||
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
|
||||
// churn, which was causing visible terminal blinking while resizing.
|
||||
return
|
||||
}
|
||||
if (pane.pendingSplitScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
return
|
||||
|
|
@ -40,7 +55,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
|
|||
export function fitAllPanesInternal(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
try {
|
||||
const dims = pane.fitAddon.proposeDimensions()
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue