|
326 | 326 | // pass { legacyGeometry: false }. |
327 | 327 | var legacyGeometry = opts.legacyGeometry !== false; |
328 | 328 | var legacyLogged = false; |
| 329 | + var legacyBaselineHeight = 0; |
| 330 | + var legacyOccluding = false; |
329 | 331 | var embedded; |
330 | 332 | try { embedded = global !== global.top; } catch (_) { embedded = true; } |
331 | 333 | // Resolved stance: relay until proven otherwise when embedded under 'auto'. |
|
339 | 341 | // once we have measured a real occlusion, our 0 means "dismissed" and must be |
340 | 342 | // sent, or the keyboard could never be torn down through this bridge. |
341 | 343 | var everOccluded = false; |
| 344 | + // Safari can eventually shrink window.innerHeight to the already-shrunk |
| 345 | + // visualViewport height while the keyboard remains docked. Keep the last |
| 346 | + // no-keyboard layout height for the active occlusion session; otherwise the |
| 347 | + // live innerHeight-vv.height delta collapses to zero after the first key. |
| 348 | + var layoutBaselineHeight = global.innerHeight || 0; |
| 349 | + var layoutBaselineWidth = global.innerWidth || 0; |
| 350 | + var measuredOccluding = false; |
| 351 | + // A fixed iframe declared as height:100% still follows Safari's shrinking |
| 352 | + // layout viewport. After the first character WebKit can collapse that box |
| 353 | + // to the area above the keyboard, exposing the embedder's background as a |
| 354 | + // large black band. Remember the pre-keyboard box and pin it in CSS pixels |
| 355 | + // for the keyboard session. Geometry/lift decides what PART is visible; |
| 356 | + // resizing the stream itself is both redundant and visibly destructive. |
| 357 | + var pinKeyboardHeight = opts.pinKeyboardHeight !== false; |
| 358 | + var frameBaselineHeight = 0; |
| 359 | + var frameBaselineWidth = 0; |
| 360 | + var framePinned = false; |
| 361 | + var frameRestoreHeight = ''; |
| 362 | + var frameRestoreBottom = ''; |
| 363 | + var frameRestoreTop = ''; |
| 364 | + var frameRestoreTransform = ''; |
| 365 | + try { |
| 366 | + var initialFrameRect = iframeEl.getBoundingClientRect(); |
| 367 | + frameBaselineHeight = initialFrameRect.height || layoutBaselineHeight; |
| 368 | + frameBaselineWidth = initialFrameRect.width || layoutBaselineWidth; |
| 369 | + } catch (_) { |
| 370 | + frameBaselineHeight = layoutBaselineHeight; |
| 371 | + frameBaselineWidth = layoutBaselineWidth; |
| 372 | + } |
342 | 373 | var blindLogged = false; |
343 | 374 | var destroyed = false; |
344 | 375 | var childHello = null; |
|
388 | 419 | } |
389 | 420 | } |
390 | 421 |
|
| 422 | + function frameHeightNow() { |
| 423 | + try { return iframeEl.getBoundingClientRect().height || 0; } catch (_) { return 0; } |
| 424 | + } |
| 425 | + |
| 426 | + function pinFrameForKeyboard(active) { |
| 427 | + if (!pinKeyboardHeight) return; |
| 428 | + var cs; |
| 429 | + try { cs = global.getComputedStyle(iframeEl); } catch (_) { return; } |
| 430 | + if (!cs || cs.position !== 'fixed') return; |
| 431 | + |
| 432 | + if (active) { |
| 433 | + if (!framePinned) { |
| 434 | + var rect; |
| 435 | + try { rect = iframeEl.getBoundingClientRect(); } catch (_) { rect = null; } |
| 436 | + if (rect && rect.width && frameBaselineWidth && Math.abs(rect.width - frameBaselineWidth) > 8) { |
| 437 | + // A rotation is a new layout. Prefer the current box only when it has |
| 438 | + // not already collapsed below the pre-keyboard layout baseline. |
| 439 | + frameBaselineWidth = rect.width; |
| 440 | + if (rect.height >= layoutBaselineHeight - 8) frameBaselineHeight = rect.height; |
| 441 | + } else if (rect && rect.height > frameBaselineHeight) { |
| 442 | + frameBaselineHeight = rect.height; |
| 443 | + } |
| 444 | + frameBaselineHeight = Math.max(frameBaselineHeight, layoutBaselineHeight); |
| 445 | + if (!(frameBaselineHeight > 0)) return; |
| 446 | + frameRestoreHeight = iframeEl.style.height || ''; |
| 447 | + frameRestoreBottom = iframeEl.style.bottom || ''; |
| 448 | + frameRestoreTop = iframeEl.style.top || ''; |
| 449 | + frameRestoreTransform = iframeEl.style.transform || ''; |
| 450 | + iframeEl.style.height = Math.round(frameBaselineHeight) + 'px'; |
| 451 | + // top + bottom + an explicit height is over-constrained. Say which two |
| 452 | + // edges own the box so WebKit cannot choose the shrunken bottom inset. |
| 453 | + iframeEl.style.bottom = 'auto'; |
| 454 | + framePinned = true; |
| 455 | + } |
| 456 | + // iOS pans the visual viewport after the first key (offsetTop becomes |
| 457 | + // roughly the keyboard height). Fixed content remains in layout-viewport |
| 458 | + // coordinates, so without this compensation the entire 714px iframe moves |
| 459 | + // up ~337px and its bottom exposes the host background. Keep its top at |
| 460 | + // the visual viewport's top; the viewer's own lift still positions the |
| 461 | + // remote field within the visible 377px. Use an integer compositor |
| 462 | + // translation only when Safari actually pans. The normal proxy placement |
| 463 | + // keeps offsetTop at zero, so the iframe retains the transform-free layout |
| 464 | + // contract and its native raster scale. |
| 465 | + var vv = global.visualViewport; |
| 466 | + var visualTop = Math.round(vv ? (vv.offsetTop || 0) : 0); |
| 467 | + iframeEl.style.top = frameRestoreTop; |
| 468 | + iframeEl.style.transform = visualTop |
| 469 | + ? 'translate3d(0,' + visualTop + 'px,0)' |
| 470 | + : frameRestoreTransform; |
| 471 | + return; |
| 472 | + } |
| 473 | + |
| 474 | + if (!framePinned) { |
| 475 | + var idleHeight = frameHeightNow(); |
| 476 | + if (idleHeight > 0) frameBaselineHeight = idleHeight; |
| 477 | + return; |
| 478 | + } |
| 479 | + iframeEl.style.height = frameRestoreHeight; |
| 480 | + iframeEl.style.bottom = frameRestoreBottom; |
| 481 | + iframeEl.style.top = frameRestoreTop; |
| 482 | + iframeEl.style.transform = frameRestoreTransform; |
| 483 | + framePinned = false; |
| 484 | + var restoredHeight = frameHeightNow(); |
| 485 | + if (restoredHeight > 0) frameBaselineHeight = restoredHeight; |
| 486 | + } |
| 487 | + |
| 488 | + function annotateGeometry(g) { |
| 489 | + var vv = global.visualViewport; |
| 490 | + g.rawInnerHeight = global.innerHeight || 0; |
| 491 | + g.rawViewportHeight = vv ? vv.height : 0; |
| 492 | + g.rawOffsetTop = vv ? (vv.offsetTop || 0) : 0; |
| 493 | + g.layoutBaselineHeight = layoutBaselineHeight; |
| 494 | + g.frameHeight = frameHeightNow(); |
| 495 | + g.framePinned = framePinned ? 1 : 0; |
| 496 | + return g; |
| 497 | + } |
| 498 | + |
391 | 499 | // ---- MEASURE ----------------------------------------------------------- |
392 | 500 | // occludedBottom is the height the keyboard covers; visibleHeight is what's |
393 | 501 | // left. Prefer the VirtualKeyboard API when this document actually has it |
394 | 502 | // (Chromium, and only where permitted) since it reports the keyboard rect |
395 | 503 | // explicitly; otherwise derive it from the visual viewport, which is the |
396 | | - // only signal iOS Safari offers. offsetTop matters: iOS shifts the visual |
397 | | - // viewport rather than only shrinking it, so height alone under-reports. |
| 504 | + // only signal iOS Safari offers. The keyboard reduces visualViewport.height; |
| 505 | + // offsetTop is the page's pan/scroll position and must NOT be subtracted from |
| 506 | + // the keyboard height. iOS raises offsetTop while keeping a lower-page field |
| 507 | + // visible during typing; subtracting it made a still-docked keyboard report |
| 508 | + // occludedBottom=0 and caused the viewer to expose a large black gap. |
398 | 509 | function measure() { |
399 | 510 | var innerH = global.innerHeight || 0; |
| 511 | + var innerW = global.innerWidth || 0; |
| 512 | + if (layoutBaselineWidth && Math.abs(innerW - layoutBaselineWidth) > 8) { |
| 513 | + // Rotation/posture change: relearn in the new width. If the keyboard is |
| 514 | + // already visible, vv.height will still be smaller and latch it below. |
| 515 | + layoutBaselineWidth = innerW; |
| 516 | + layoutBaselineHeight = innerH; |
| 517 | + measuredOccluding = false; |
| 518 | + } else { |
| 519 | + layoutBaselineWidth = innerW; |
| 520 | + if (innerH > layoutBaselineHeight) layoutBaselineHeight = innerH; |
| 521 | + } |
400 | 522 | var vk = global.navigator && global.navigator.virtualKeyboard; |
401 | 523 | if (vk && vk.boundingRect && vk.boundingRect.height > 0) { |
| 524 | + measuredOccluding = true; |
402 | 525 | return { visibleHeight: innerH - vk.boundingRect.height, occludedBottom: vk.boundingRect.height }; |
403 | 526 | } |
404 | 527 | var vv = global.visualViewport; |
405 | 528 | if (vv) { |
406 | | - var occluded = Math.max(0, innerH - vv.height - (vv.offsetTop || 0)); |
| 529 | + // When both viewports agree and no keyboard session has been observed, |
| 530 | + // this is ordinary page/browser-chrome geometry. Relearn downward as |
| 531 | + // well so an expanded URL bar is not mistaken for a keyboard. |
| 532 | + if (!measuredOccluding && Math.abs(innerH - vv.height) < 50) { |
| 533 | + layoutBaselineHeight = innerH; |
| 534 | + return { visibleHeight: innerH, occludedBottom: 0 }; |
| 535 | + } |
| 536 | + var occluded = Math.max(0, Math.max(layoutBaselineHeight, innerH) - vv.height); |
407 | 537 | // Under ~50px is not a keyboard — it's a URL bar collapsing, a pinch, or |
408 | 538 | // fractional rounding. Report it as "no keyboard" so the viewer clears |
409 | 539 | // its lift instead of nudging the page around. |
410 | | - if (occluded < 50) return { visibleHeight: innerH, occludedBottom: 0 }; |
| 540 | + if (occluded < 50) { |
| 541 | + measuredOccluding = false; |
| 542 | + if (Math.abs(innerH - vv.height) < 50) layoutBaselineHeight = innerH; |
| 543 | + return { visibleHeight: innerH, occludedBottom: 0 }; |
| 544 | + } |
| 545 | + measuredOccluding = true; |
411 | 546 | return { visibleHeight: vv.height, occludedBottom: occluded }; |
412 | 547 | } |
413 | 548 | return { visibleHeight: innerH, occludedBottom: 0 }; |
|
417 | 552 | if (relay) return; // a relaying frame forwards its parent's numbers, never its own |
418 | 553 | if (destroyed) return; |
419 | 554 | var g = measure(); |
| 555 | + pinFrameForKeyboard(g.occludedBottom > 0); |
| 556 | + annotateGeometry(g); |
420 | 557 | // A FALLBACK measurer (embedded, nobody above us proved it measures) must |
421 | 558 | // never assert "there is no keyboard". Our visualViewport is a subframe's: it |
422 | 559 | // does not reliably shrink when the keyboard opens, so occludedBottom:0 from |
|
604 | 741 | if (legacyGeometry && d.type === 'parent-viewport') { |
605 | 742 | var innerH = Number(d.innerHeight); |
606 | 743 | var visH = Number(d.viewportHeight); |
607 | | - var offTop = Number(d.offsetTop) || 0; |
608 | 744 | if (isFinite(innerH) && innerH > 0 && isFinite(visH) && visH > 0) { |
609 | | - var occ = Math.max(0, innerH - visH - offTop); |
| 745 | + // offsetTop is scroll position, not keyboard occlusion. This mirrors |
| 746 | + // measure() above for the legacy portal message shape. |
| 747 | + if (!legacyBaselineHeight || innerH > legacyBaselineHeight) legacyBaselineHeight = innerH; |
| 748 | + if (!legacyOccluding && Math.abs(innerH - visH) < 50) legacyBaselineHeight = innerH; |
| 749 | + var occ = Math.max(0, Math.max(legacyBaselineHeight, innerH) - visH); |
610 | 750 | // Same sub-threshold rule as measure(): under ~50px is a collapsing URL |
611 | 751 | // bar or rounding, not a keyboard. |
612 | | - if (occ < 50) { occ = 0; visH = innerH; } |
| 752 | + if (occ < 50) { |
| 753 | + occ = 0; |
| 754 | + visH = innerH; |
| 755 | + legacyOccluding = false; |
| 756 | + if (Math.abs(innerH - Number(d.viewportHeight)) < 50) legacyBaselineHeight = innerH; |
| 757 | + } else { |
| 758 | + legacyOccluding = true; |
| 759 | + } |
613 | 760 | if (!legacyLogged) { |
614 | 761 | legacyLogged = true; |
615 | 762 | logInfo('translating the legacy parent-viewport message into POPCORN_HOST_GEOMETRY'); |
|
619 | 766 | if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; } |
620 | 767 | if (!relay) { stopMeasuring(); relay = true; logInfo('upstream host is measuring (legacy bridge) — switching to relay'); } |
621 | 768 | } |
622 | | - post('POPCORN_HOST_GEOMETRY', { visibleHeight: visH, occludedBottom: occ }); |
| 769 | + pinFrameForKeyboard(occ > 0); |
| 770 | + post('POPCORN_HOST_GEOMETRY', annotateGeometry({ visibleHeight: visH, occludedBottom: occ })); |
623 | 771 | } |
624 | 772 | return; |
625 | 773 | } |
|
631 | 779 | if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; } |
632 | 780 | if (!relay) { stopMeasuring(); relay = true; logInfo('upstream host is measuring — switching to relay'); } |
633 | 781 | } |
| 782 | + if (d.type === 'POPCORN_HOST_GEOMETRY') { |
| 783 | + pinFrameForKeyboard(Number(d.occludedBottom) > 0); |
| 784 | + } |
634 | 785 | post(d.type, d); |
635 | 786 | } |
636 | 787 | } |
|
732 | 883 | stopHelloRetry(); |
733 | 884 | if (graceTimer) { global.clearTimeout(graceTimer); graceTimer = null; } |
734 | 885 | stopMeasuring(); |
| 886 | + pinFrameForKeyboard(false); |
735 | 887 | if (resizeWatch) { try { resizeWatch.disconnect(); } catch (_) {} resizeWatch = null; } |
736 | 888 | listeners = {}; |
737 | 889 | }, |
|
0 commit comments