d18f2e2d
fix: keep IME focus when a copy control hides itself
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -144,7 +144,6 @@ class Tile { | |||
| 144 | this.copyUiOwner = null; // null, 'clipboard', or 'selection' | 144 | this.copyUiOwner = null; // null, 'clipboard', or 'selection' |
| 145 | this.selectionCopyVersion = 0; | 145 | this.selectionCopyVersion = 0; |
| 146 | this.viewStartRow = 0; | 146 | this.viewStartRow = 0; |
| 147 | this.requestedViewStartRow = 0; | ||
| 148 | this.scrollRequest = null; // {fromPages, start, count}; at most one in flight | 147 | this.scrollRequest = null; // {fromPages, start, count}; at most one in flight |
| 149 | this.scrollRequestTimer = null; | 148 | this.scrollRequestTimer = null; |
| 150 | this.selection = null; // {anchor, active, requestId, text} | 149 | this.selection = null; // {anchor, active, requestId, text} |
| @@ -527,7 +526,6 @@ class Tile { | |||
| 527 | this.clearScrollRequestTimer(); | 526 | this.clearScrollRequestTimer(); |
| 528 | this.scrollRequest = null; | 527 | this.scrollRequest = null; |
| 529 | this.viewStartRow = start; | 528 | this.viewStartRow = start; |
| 530 | this.requestedViewStartRow = start; | ||
| 531 | this.moveDragToCurrentPointer(); | 529 | this.moveDragToCurrentPointer(); |
| 532 | this.paintScroll(); | 530 | this.paintScroll(); |
| 533 | } else this.scrollRequestFailed(request); | 531 | } else this.scrollRequestFailed(request); |
| @@ -582,6 +580,20 @@ class Tile { | |||
| 582 | this.copyButton.textContent = text; | 580 | this.copyButton.textContent = text; |
| 583 | } | 581 | } |
| 584 | 582 | ||
| 583 | // `.copy-request` is display:none without `.on`, and hiding a focused | ||
| 584 | // element drops focus to <body>. The hidden IME is what receives | ||
| 585 | // compositionend, so a tile that lost focus that way accepts no IME input | ||
| 586 | // at all until the user clicks — and nothing on screen says why. The | ||
| 587 | // paths that need this are the ones nobody asked for: the timers that | ||
| 588 | // retire feedback under a button the user tabbed to (mux.js's Tab | ||
| 589 | // affordance) and never activated. Every user-driven exit already hands | ||
| 590 | // focus back in the click handler and is left alone. | ||
| 591 | restoreImeFocusIfHidden() { | ||
| 592 | if (document.activeElement !== this.copyButton) return; | ||
| 593 | if (this.copyButton.classList.contains('on')) return; | ||
| 594 | ime.focus(); | ||
| 595 | } | ||
| 596 | |||
| 585 | releaseSelectionCopyUi() { | 597 | releaseSelectionCopyUi() { |
| 586 | if (this.copyUiOwner !== 'selection') return; | 598 | if (this.copyUiOwner !== 'selection') return; |
| 587 | if (this.pendingClipboard !== null) { | 599 | if (this.pendingClipboard !== null) { |
| @@ -636,6 +648,7 @@ class Tile { | |||
| 636 | if (version !== this.clipboardVersion || this.pendingClipboard !== null || | 648 | if (version !== this.clipboardVersion || this.pendingClipboard !== null || |
| 637 | this.copyUiOwner !== 'clipboard') return; | 649 | this.copyUiOwner !== 'clipboard') return; |
| 638 | this.renderCopyUi(null, 'copy-request', 'Copy'); | 650 | this.renderCopyUi(null, 'copy-request', 'Copy'); |
| 651 | this.restoreImeFocusIfHidden(); | ||
| 639 | }, 1200); | 652 | }, 1200); |
| 640 | } | 653 | } |
| 641 | 654 | ||
| @@ -689,6 +702,7 @@ class Tile { | |||
| 689 | if (!this.zoomed || this.selection !== selection || | 702 | if (!this.zoomed || this.selection !== selection || |
| 690 | version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return; | 703 | version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return; |
| 691 | this.releaseSelectionCopyUi(); | 704 | this.releaseSelectionCopyUi(); |
| 705 | this.restoreImeFocusIfHidden(); | ||
| 692 | }, 1200); | 706 | }, 1200); |
| 693 | } | 707 | } |
| 694 | 708 | ||
| @@ -761,6 +775,14 @@ class Tile { | |||
| 761 | if (repaint && hadSelection && this.core) this.reflow(); | 775 | if (repaint && hadSelection && this.core) this.reflow(); |
| 762 | } | 776 | } |
| 763 | 777 | ||
| 778 | // Nulling the handle and bumping the generation are ONE fact, not two: | ||
| 779 | // nothing revokes a selection request without doing both, in this one | ||
| 780 | // place. That makes the handle-identity comparisons downstream subsume | ||
| 781 | // the generation comparisons beside them — several of those conditions | ||
| 782 | // cannot fire on their own. They are deliberate belt-and-braces against a | ||
| 783 | // future second revoker that forgets half the act, and are not the | ||
| 784 | // invariant themselves. Do not "simplify" one away in isolation: the | ||
| 785 | // pairing here is what would have to change first. | ||
| 764 | invalidateSelectionRequest() { | 786 | invalidateSelectionRequest() { |
| 765 | if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer); | 787 | if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer); |
| 766 | this.selectionRequestTimer = null; | 788 | this.selectionRequestTimer = null; |
| @@ -785,6 +807,12 @@ class Tile { | |||
| 785 | return request; | 807 | return request; |
| 786 | } | 808 | } |
| 787 | 809 | ||
| 810 | // The two timers below compare setTimeout HANDLES by identity. Per the | ||
| 811 | // HTML spec an id is unique only among timers that are still active, so a | ||
| 812 | // handle can legitimately be reused once its predecessor has fired or | ||
| 813 | // been cleared. That is why the version fields exist beside it rather | ||
| 814 | // than instead of it: identity is the cheap first check, and the | ||
| 815 | // monotonic generation is what actually settles which request is meant. | ||
| 788 | startSelectionRequestTimeout(request) { | 816 | startSelectionRequestTimeout(request) { |
| 789 | const selection = request.selection; | 817 | const selection = request.selection; |
| 790 | const timer = setTimeout(() => { | 818 | const timer = setTimeout(() => { |
| @@ -818,6 +846,7 @@ class Tile { | |||
| 818 | this.selection !== selection || this.copyUiOwner !== 'selection') return; | 846 | this.selection !== selection || this.copyUiOwner !== 'selection') return; |
| 819 | this.selectionFeedbackVersion++; | 847 | this.selectionFeedbackVersion++; |
| 820 | this.releaseSelectionCopyUi(); | 848 | this.releaseSelectionCopyUi(); |
| 849 | this.restoreImeFocusIfHidden(); | ||
| 821 | }, 1200); | 850 | }, 1200); |
| 822 | this.selectionFeedbackTimer = timer; | 851 | this.selectionFeedbackTimer = timer; |
| 823 | } | 852 | } |
| @@ -985,7 +1014,6 @@ class Tile { | |||
| 985 | } | 1014 | } |
| 986 | paintLive() { | 1015 | paintLive() { |
| 987 | this.viewStartRow = this.core.mux_history_rows(); | 1016 | this.viewStartRow = this.core.mux_history_rows(); |
| 988 | this.requestedViewStartRow = this.viewStartRow; | ||
| 989 | this.sizeCanvas(); | 1017 | this.sizeCanvas(); |
| 990 | const n = this.core.mux_read_viewport(); | 1018 | const n = this.core.mux_read_viewport(); |
| 991 | const paintedRows = new Set(); | 1019 | const paintedRows = new Set(); |
| @@ -1095,7 +1123,6 @@ class Tile { | |||
| 1095 | this.scrollPages = next; | 1123 | this.scrollPages = next; |
| 1096 | this.renderBadge(); | 1124 | this.renderBadge(); |
| 1097 | const start = this.core.mux_scroll_start(this.scrollPages, rows); | 1125 | const start = this.core.mux_scroll_start(this.scrollPages, rows); |
| 1098 | this.requestedViewStartRow = start; | ||
| 1099 | const request = { fromPages, start, count: rows }; | 1126 | const request = { fromPages, start, count: rows }; |
| 1100 | this.scrollRequest = request; | 1127 | this.scrollRequest = request; |
| 1101 | const p = new Uint8Array(6); | 1128 | const p = new Uint8Array(6); |
| @@ -1130,7 +1157,6 @@ class Tile { | |||
| 1130 | this.clearScrollRequestTimer(); | 1157 | this.clearScrollRequestTimer(); |
| 1131 | this.scrollRequest = null; | 1158 | this.scrollRequest = null; |
| 1132 | this.scrollPages = request.fromPages; | 1159 | this.scrollPages = request.fromPages; |
| 1133 | this.requestedViewStartRow = this.viewStartRow; | ||
| 1134 | this.renderBadge(); | 1160 | this.renderBadge(); |
| 1135 | } | 1161 | } |
| 1136 | 1162 | ||
| @@ -1313,7 +1339,14 @@ document.addEventListener('paste', (ev) => { | |||
| 1313 | if (!zoomedTile) return; | 1339 | if (!zoomedTile) return; |
| 1314 | ev.preventDefault(); | 1340 | ev.preventDefault(); |
| 1315 | const text = ev.clipboardData?.getData('text'); | 1341 | const text = ev.clipboardData?.getData('text'); |
| 1316 | if (text) zoomedTile.sendPaste(text); | 1342 | if (!text) return; |
| 1343 | // Input returns the tile to live, exactly as every keystroke path does. | ||
| 1344 | // It is NOT swallowed the way a stray keystroke is: a paste is never | ||
| 1345 | // stray — it carries content the user explicitly asked to send, and | ||
| 1346 | // discarding it would cost them the copy as well as the page. | ||
| 1347 | zoomedTile.clearSelection(false); | ||
| 1348 | zoomedTile.exitScroll(); | ||
| 1349 | zoomedTile.sendPaste(text); | ||
| 1317 | }); | 1350 | }); |
| 1318 | ime.addEventListener('compositionend', (ev) => { | 1351 | ime.addEventListener('compositionend', (ev) => { |
| 1319 | // sendText, NOT sendPaste: composed text is typing (see sendText). | 1352 | // sendText, NOT sendPaste: composed text is typing (see sendText). |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -378,7 +378,14 @@ function browserShell(source) { | |||
| 378 | } | 378 | } |
| 379 | return null; | 379 | return null; |
| 380 | } | 380 | } |
| 381 | addEventListener(type, fn) { this.listeners.set(type, fn); } | 381 | // Keyed by type, ONE listener each: the page registers exactly one per |
| 382 | // (element, type) today, and silently keeping only the last would mean | ||
| 383 | // a future double-registration was tested in name only. | ||
| 384 | addEventListener(type, fn) { | ||
| 385 | if (this.listeners.has(type)) | ||
| 386 | throw new Error(`FakeElement: second ${type} listener on <${this.tagName}>`); | ||
| 387 | this.listeners.set(type, fn); | ||
| 388 | } | ||
| 382 | dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } | 389 | dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } |
| 383 | getBoundingClientRect() { return this.rect; } | 390 | getBoundingClientRect() { return this.rect; } |
| 384 | setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } | 391 | setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } |
| @@ -548,6 +555,17 @@ async function verifyClipboardShell(shell, html) { | |||
| 548 | check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on'); | 555 | check('automatic success makes feedback visible', snap.tile.copyButton.className, 'copy-request on'); |
| 549 | check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200); | 556 | check('automatic success schedules 1200ms hide', h.timers.at(-1)?.ms, 1200); |
| 550 | 557 | ||
| 558 | // The Tab affordance makes the copy control focusable, and the 1200ms | ||
| 559 | // timer then hides it. Focus would land on <body>, the hidden IME would | ||
| 560 | // stop seeing compositionend, and IME input would be dead until a click. | ||
| 561 | const hiddenUnderFocus = tileFor('hidden while focused'); | ||
| 562 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | ||
| 563 | await hiddenUnderFocus.tile.onClipboardEffect(); | ||
| 564 | const hiddenUnderFocusTimer = h.timers.at(-1); | ||
| 565 | hiddenUnderFocus.tile.copyButton.focus(); | ||
| 566 | hiddenUnderFocusTimer.fn(); | ||
| 567 | check('hiding the focused copy control returns focus to the IME', h.document.activeElement, h.elements.ime); | ||
| 568 | |||
| 551 | const fallback = tileFor('retry me'); | 569 | const fallback = tileFor('retry me'); |
| 552 | h.navigator.clipboard = undefined; | 570 | h.navigator.clipboard = undefined; |
| 553 | await fallback.tile.onClipboardEffect(); | 571 | await fallback.tile.onClipboardEffect(); |
| @@ -762,7 +780,6 @@ async function verifySelectionShell(shell, html) { | |||
| 762 | && tile.selectionFeedbackVersion === 0 | 780 | && tile.selectionFeedbackVersion === 0 |
| 763 | && tile.selectionCopyVersion === 0; | 781 | && tile.selectionCopyVersion === 0; |
| 764 | check('tile initializes retained selection state', initialState, true); | 782 | check('tile initializes retained selection state', initialState, true); |
| 765 | check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); | ||
| 766 | check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); | 783 | check('tile initializes shared copy-control ownership', tile.copyUiOwner, null); |
| 767 | check( | 784 | check( |
| 768 | 'tile binds all pointer selection events', | 785 | 'tile binds all pointer selection events', |
| @@ -907,7 +924,6 @@ async function verifySelectionShell(shell, html) { | |||
| 907 | droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile); | 924 | droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile); |
| 908 | droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } }; | 925 | droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } }; |
| 909 | droppedScroll.tile.viewStartRow = 30; | 926 | droppedScroll.tile.viewStartRow = 30; |
| 910 | droppedScroll.tile.requestedViewStartRow = 30; | ||
| 911 | check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false); | 927 | check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false); |
| 912 | check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0); | 928 | check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0); |
| 913 | check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null); | 929 | check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null); |
| @@ -915,7 +931,6 @@ async function verifySelectionShell(shell, html) { | |||
| 915 | 931 | ||
| 916 | const timeoutScroll = makeTile(); | 932 | const timeoutScroll = makeTile(); |
| 917 | timeoutScroll.tile.viewStartRow = 30; | 933 | timeoutScroll.tile.viewStartRow = 30; |
| 918 | timeoutScroll.tile.requestedViewStartRow = 30; | ||
| 919 | timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2)); | 934 | timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2)); |
| 920 | timeoutScroll.tile.canvas.dispatchEvent('pointermove', { | 935 | timeoutScroll.tile.canvas.dispatchEvent('pointermove', { |
| 921 | ...pointer(25, 4, 0), clientY: 10, | 936 | ...pointer(25, 4, 0), clientY: 10, |
| @@ -936,7 +951,6 @@ async function verifySelectionShell(shell, html) { | |||
| 936 | 951 | ||
| 937 | const staleTimeout = makeTile(); | 952 | const staleTimeout = makeTile(); |
| 938 | staleTimeout.tile.viewStartRow = 30; | 953 | staleTimeout.tile.viewStartRow = 30; |
| 939 | staleTimeout.tile.requestedViewStartRow = 30; | ||
| 940 | staleTimeout.tile.paintScroll = () => {}; | 954 | staleTimeout.tile.paintScroll = () => {}; |
| 941 | staleTimeout.tile.changeScrollPages(1); | 955 | staleTimeout.tile.changeScrollPages(1); |
| 942 | const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1]; | 956 | const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1]; |
| @@ -950,7 +964,6 @@ async function verifySelectionShell(shell, html) { | |||
| 950 | 964 | ||
| 951 | const lifecycleScroll = makeTile(); | 965 | const lifecycleScroll = makeTile(); |
| 952 | lifecycleScroll.tile.viewStartRow = 30; | 966 | lifecycleScroll.tile.viewStartRow = 30; |
| 953 | lifecycleScroll.tile.requestedViewStartRow = 30; | ||
| 954 | lifecycleScroll.tile.connect(); | 967 | lifecycleScroll.tile.connect(); |
| 955 | lifecycleScroll.tile.changeScrollPages(1); | 968 | lifecycleScroll.tile.changeScrollPages(1); |
| 956 | const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1]; | 969 | const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1]; |
| @@ -965,7 +978,6 @@ async function verifySelectionShell(shell, html) { | |||
| 965 | 978 | ||
| 966 | const attachScroll = makeTile(); | 979 | const attachScroll = makeTile(); |
| 967 | attachScroll.tile.viewStartRow = 30; | 980 | attachScroll.tile.viewStartRow = 30; |
| 968 | attachScroll.tile.requestedViewStartRow = 30; | ||
| 969 | attachScroll.tile.changeScrollPages(1); | 981 | attachScroll.tile.changeScrollPages(1); |
| 970 | const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1]; | 982 | const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1]; |
| 971 | attachScroll.tile.sendAttach(false); | 983 | attachScroll.tile.sendAttach(false); |
| @@ -974,7 +986,6 @@ async function verifySelectionShell(shell, html) { | |||
| 974 | 986 | ||
| 975 | const controlLifecycle = makeTile(); | 987 | const controlLifecycle = makeTile(); |
| 976 | controlLifecycle.tile.viewStartRow = 30; | 988 | controlLifecycle.tile.viewStartRow = 30; |
| 977 | controlLifecycle.tile.requestedViewStartRow = 30; | ||
| 978 | controlLifecycle.tile.changeScrollPages(1); | 989 | controlLifecycle.tile.changeScrollPages(1); |
| 979 | const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1]; | 990 | const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1]; |
| 980 | const reconnectingControl = Uint8Array.from([ | 991 | const reconnectingControl = Uint8Array.from([ |
| @@ -986,7 +997,6 @@ async function verifySelectionShell(shell, html) { | |||
| 986 | 997 | ||
| 987 | const exitLifecycle = makeTile(); | 998 | const exitLifecycle = makeTile(); |
| 988 | exitLifecycle.tile.viewStartRow = 30; | 999 | exitLifecycle.tile.viewStartRow = 30; |
| 989 | exitLifecycle.tile.requestedViewStartRow = 30; | ||
| 990 | exitLifecycle.tile.changeScrollPages(1); | 1000 | exitLifecycle.tile.changeScrollPages(1); |
| 991 | const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1]; | 1001 | const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1]; |
| 992 | exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0])); | 1002 | exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0])); |
| @@ -1009,7 +1019,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1009 | 1019 | ||
| 1010 | const countMismatch = makeTile(); | 1020 | const countMismatch = makeTile(); |
| 1011 | countMismatch.tile.viewStartRow = 30; | 1021 | countMismatch.tile.viewStartRow = 30; |
| 1012 | countMismatch.tile.requestedViewStartRow = 30; | ||
| 1013 | let countPaints = 0; | 1022 | let countPaints = 0; |
| 1014 | countMismatch.tile.paintScroll = () => { countPaints++; }; | 1023 | countMismatch.tile.paintScroll = () => { countPaints++; }; |
| 1015 | countMismatch.tile.changeScrollPages(1); | 1024 | countMismatch.tile.changeScrollPages(1); |
| @@ -1021,7 +1030,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1021 | 1030 | ||
| 1022 | const resizeMismatch = makeTile(); | 1031 | const resizeMismatch = makeTile(); |
| 1023 | resizeMismatch.tile.viewStartRow = 30; | 1032 | resizeMismatch.tile.viewStartRow = 30; |
| 1024 | resizeMismatch.tile.requestedViewStartRow = 30; | ||
| 1025 | resizeMismatch.tile.changeScrollPages(1); | 1033 | resizeMismatch.tile.changeScrollPages(1); |
| 1026 | const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1]; | 1034 | const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1]; |
| 1027 | resizeMismatch.tile.zoomCols = () => 11; | 1035 | resizeMismatch.tile.zoomCols = () => 11; |
| @@ -1041,7 +1049,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1041 | }; | 1049 | }; |
| 1042 | snapshotHistory.tile.scrollPages = 2; | 1050 | snapshotHistory.tile.scrollPages = 2; |
| 1043 | snapshotHistory.tile.viewStartRow = 20; | 1051 | snapshotHistory.tile.viewStartRow = 20; |
| 1044 | snapshotHistory.tile.requestedViewStartRow = 20; | ||
| 1045 | snapshotHistory.tile.selection = { | 1052 | snapshotHistory.tile.selection = { |
| 1046 | anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid', | 1053 | anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid', |
| 1047 | }; | 1054 | }; |
| @@ -1062,7 +1069,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1062 | check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1); | 1069 | check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1); |
| 1063 | check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0); | 1070 | check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0); |
| 1064 | check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40); | 1071 | check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40); |
| 1065 | check('snapshot live repaint synchronizes requested start', snapshotHistory.tile.requestedViewStartRow, 40); | ||
| 1066 | check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null); | 1072 | check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null); |
| 1067 | check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true); | 1073 | check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true); |
| 1068 | check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null); | 1074 | check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null); |
| @@ -1076,7 +1082,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1076 | const failedSnapshot = makeTile(); | 1082 | const failedSnapshot = makeTile(); |
| 1077 | failedSnapshot.tile.scrollPages = 2; | 1083 | failedSnapshot.tile.scrollPages = 2; |
| 1078 | failedSnapshot.tile.viewStartRow = 20; | 1084 | failedSnapshot.tile.viewStartRow = 20; |
| 1079 | failedSnapshot.tile.requestedViewStartRow = 20; | ||
| 1080 | failedSnapshot.tile.selection = { | 1085 | failedSnapshot.tile.selection = { |
| 1081 | anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid', | 1086 | anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid', |
| 1082 | }; | 1087 | }; |
| @@ -1231,7 +1236,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1231 | scrolling.tile.viewStartRow = 30; | 1236 | scrolling.tile.viewStartRow = 30; |
| 1232 | scrolling.tile.onWheel({ deltaY: -1 }); | 1237 | scrolling.tile.onWheel({ deltaY: -1 }); |
| 1233 | check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30); | 1238 | check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30); |
| 1234 | check('wheel retains the separately requested start row', scrolling.tile.requestedViewStartRow, 25); | ||
| 1235 | check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); | 1239 | check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); |
| 1236 | scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2)); | 1240 | scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2)); |
| 1237 | check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32); | 1241 | check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32); |
| @@ -1282,7 +1286,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1282 | 1286 | ||
| 1283 | const auto = makeTile(); | 1287 | const auto = makeTile(); |
| 1284 | auto.tile.viewStartRow = 30; | 1288 | auto.tile.viewStartRow = 30; |
| 1285 | auto.tile.requestedViewStartRow = 30; | ||
| 1286 | let autoPaints = 0; | 1289 | let autoPaints = 0; |
| 1287 | auto.tile.paintScroll = () => { autoPaints++; }; | 1290 | auto.tile.paintScroll = () => { autoPaints++; }; |
| 1288 | auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2)); | 1291 | auto.tile.canvas.dispatchEvent('pointerdown', pointer(18, 2, 2)); |
| @@ -1295,7 +1298,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1295 | check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05); | 1298 | check('auto-scroll uses fetch-scrollback wire type', auto.sent[0]?.type, 0x05); |
| 1296 | check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30); | 1299 | check('auto-scroll keeps coordinates tied to painted page while pending', auto.tile.selection.active.row, 30); |
| 1297 | check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30); | 1300 | check('auto-scroll keeps painted start unchanged while pending', auto.tile.viewStartRow, 30); |
| 1298 | check('auto-scroll records requested page separately', auto.tile.requestedViewStartRow, 25); | ||
| 1299 | autoTimer.fn(); | 1301 | autoTimer.fn(); |
| 1300 | check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1); | 1302 | check('auto-scroll does not stack requests while a paint is pending', auto.sent.length, 1); |
| 1301 | auto.tile.onMessage(scrollEnvelope(20)); | 1303 | auto.tile.onMessage(scrollEnvelope(20)); |
| @@ -1319,7 +1321,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1319 | 1321 | ||
| 1320 | const insideDrift = makeTile(); | 1322 | const insideDrift = makeTile(); |
| 1321 | insideDrift.tile.viewStartRow = 30; | 1323 | insideDrift.tile.viewStartRow = 30; |
| 1322 | insideDrift.tile.requestedViewStartRow = 30; | ||
| 1323 | insideDrift.tile.paintScroll = () => {}; | 1324 | insideDrift.tile.paintScroll = () => {}; |
| 1324 | insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2)); | 1325 | insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2)); |
| 1325 | insideDrift.tile.canvas.dispatchEvent('pointermove', { | 1326 | insideDrift.tile.canvas.dispatchEvent('pointermove', { |
| @@ -1335,7 +1336,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1335 | 1336 | ||
| 1336 | const outsideXDrift = makeTile(); | 1337 | const outsideXDrift = makeTile(); |
| 1337 | outsideXDrift.tile.viewStartRow = 30; | 1338 | outsideXDrift.tile.viewStartRow = 30; |
| 1338 | outsideXDrift.tile.requestedViewStartRow = 30; | ||
| 1339 | outsideXDrift.tile.paintScroll = () => {}; | 1339 | outsideXDrift.tile.paintScroll = () => {}; |
| 1340 | outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2)); | 1340 | outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2)); |
| 1341 | outsideXDrift.tile.canvas.dispatchEvent('pointermove', { | 1341 | outsideXDrift.tile.canvas.dispatchEvent('pointermove', { |
| @@ -1352,7 +1352,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1352 | 1352 | ||
| 1353 | const failedAuto = makeTile(); | 1353 | const failedAuto = makeTile(); |
| 1354 | failedAuto.tile.viewStartRow = 30; | 1354 | failedAuto.tile.viewStartRow = 30; |
| 1355 | failedAuto.tile.requestedViewStartRow = 30; | ||
| 1356 | failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2)); | 1355 | failedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(19, 2, 2)); |
| 1357 | failedAuto.tile.canvas.dispatchEvent('pointermove', { | 1356 | failedAuto.tile.canvas.dispatchEvent('pointermove', { |
| 1358 | ...pointer(19, 4, 0), clientY: 10, | 1357 | ...pointer(19, 4, 0), clientY: 10, |
| @@ -1378,7 +1377,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1378 | 1377 | ||
| 1379 | const failedAutoStage = makeTile(); | 1378 | const failedAutoStage = makeTile(); |
| 1380 | failedAutoStage.tile.viewStartRow = 30; | 1379 | failedAutoStage.tile.viewStartRow = 30; |
| 1381 | failedAutoStage.tile.requestedViewStartRow = 30; | ||
| 1382 | failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2)); | 1380 | failedAutoStage.tile.canvas.dispatchEvent('pointerdown', pointer(22, 2, 2)); |
| 1383 | failedAutoStage.tile.canvas.dispatchEvent('pointermove', { | 1381 | failedAutoStage.tile.canvas.dispatchEvent('pointermove', { |
| 1384 | ...pointer(22, 4, 0), clientY: 10, | 1382 | ...pointer(22, 4, 0), clientY: 10, |
| @@ -1398,7 +1396,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1398 | 1396 | ||
| 1399 | const malformedAuto = makeTile(); | 1397 | const malformedAuto = makeTile(); |
| 1400 | malformedAuto.tile.viewStartRow = 30; | 1398 | malformedAuto.tile.viewStartRow = 30; |
| 1401 | malformedAuto.tile.requestedViewStartRow = 30; | ||
| 1402 | malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2)); | 1399 | malformedAuto.tile.canvas.dispatchEvent('pointerdown', pointer(24, 2, 2)); |
| 1403 | malformedAuto.tile.canvas.dispatchEvent('pointermove', { | 1400 | malformedAuto.tile.canvas.dispatchEvent('pointermove', { |
| 1404 | ...pointer(24, 4, 0), clientY: 10, | 1401 | ...pointer(24, 4, 0), clientY: 10, |
| @@ -1413,7 +1410,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1413 | const edgeAuto = makeTile(); | 1410 | const edgeAuto = makeTile(); |
| 1414 | edgeAuto.tile.scrollPages = 6; | 1411 | edgeAuto.tile.scrollPages = 6; |
| 1415 | edgeAuto.tile.viewStartRow = 0; | 1412 | edgeAuto.tile.viewStartRow = 0; |
| 1416 | edgeAuto.tile.requestedViewStartRow = 0; | ||
| 1417 | edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0)); | 1413 | edgeAuto.tile.canvas.dispatchEvent('pointerdown', pointer(20, 3, 0)); |
| 1418 | edgeAuto.tile.canvas.dispatchEvent('pointermove', { | 1414 | edgeAuto.tile.canvas.dispatchEvent('pointermove', { |
| 1419 | ...pointer(20, 3, 0), clientY: 10, | 1415 | ...pointer(20, 3, 0), clientY: 10, |
| @@ -1423,9 +1419,34 @@ async function verifySelectionShell(shell, html) { | |||
| 1423 | check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0); | 1419 | check('bounded auto-scroll retains painted endpoint', edgeAuto.tile.selection.active.row, 0); |
| 1424 | edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 }); | 1420 | edgeAuto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 20 }); |
| 1425 | 1421 | ||
| 1422 | // A drag that leaves the canvas at the very top never changes cell — | ||
| 1423 | // cellAtPointer clamps to view row 0 — so `moved` can only become true | ||
| 1424 | // when auto-scroll remaps the endpoint onto a genuinely different | ||
| 1425 | // absolute row. Without that remap setting the flag, pointerup takes the | ||
| 1426 | // plain-click branch and throws a multi-page selection away. | ||
| 1427 | const clampedDrag = makeTile(); | ||
| 1428 | clampedDrag.tile.viewStartRow = 30; | ||
| 1429 | clampedDrag.tile.paintScroll = () => {}; | ||
| 1430 | clampedDrag.tile.canvas.dispatchEvent('pointerdown', pointer(44, 2, 0)); | ||
| 1431 | clampedDrag.tile.canvas.dispatchEvent('pointermove', { | ||
| 1432 | ...pointer(44, 2, 0), clientY: 10, | ||
| 1433 | }); | ||
| 1434 | check('a drag clamped at the top edge has not moved a cell yet', clampedDrag.tile.selection.active.row, 30); | ||
| 1435 | h.intervals[clampedDrag.tile.selectionScrollTimer - 1].fn(); | ||
| 1436 | clampedDrag.tile.onMessage(scrollEnvelope(25)); | ||
| 1437 | check('the painted older page remaps the clamped endpoint', clampedDrag.tile.selection.active.row, 25); | ||
| 1438 | clampedDrag.tile.canvas.dispatchEvent('pointerup', { | ||
| 1439 | ...pointer(44, 2, 0), clientY: 10, | ||
| 1440 | }); | ||
| 1441 | check('a page-crossing drag is not discarded as a plain click', clampedDrag.tile.selection !== null, true); | ||
| 1442 | check( | ||
| 1443 | 'a page-crossing drag requests the rows it actually spanned', | ||
| 1444 | JSON.stringify(clampedDrag.requestCalls), | ||
| 1445 | JSON.stringify([[1, 30, 2, 25, 2]]), | ||
| 1446 | ); | ||
| 1447 | |||
| 1426 | const releaseRace = makeTile(); | 1448 | const releaseRace = makeTile(); |
| 1427 | releaseRace.tile.viewStartRow = 30; | 1449 | releaseRace.tile.viewStartRow = 30; |
| 1428 | releaseRace.tile.requestedViewStartRow = 30; | ||
| 1429 | let releasePaints = 0; | 1450 | let releasePaints = 0; |
| 1430 | releaseRace.tile.paintScroll = () => { releasePaints++; }; | 1451 | releaseRace.tile.paintScroll = () => { releasePaints++; }; |
| 1431 | releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2)); | 1452 | releaseRace.tile.canvas.dispatchEvent('pointerdown', pointer(21, 2, 2)); |
| @@ -1446,7 +1467,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1446 | 1467 | ||
| 1447 | const finalUp = makeTile(); | 1468 | const finalUp = makeTile(); |
| 1448 | finalUp.tile.viewStartRow = 30; | 1469 | finalUp.tile.viewStartRow = 30; |
| 1449 | finalUp.tile.requestedViewStartRow = 30; | ||
| 1450 | finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1)); | 1470 | finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1)); |
| 1451 | finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4)); | 1471 | finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4)); |
| 1452 | check( | 1472 | check( |
| @@ -1458,7 +1478,6 @@ async function verifySelectionShell(shell, html) { | |||
| 1458 | 1478 | ||
| 1459 | const finalPendingUp = makeTile(); | 1479 | const finalPendingUp = makeTile(); |
| 1460 | finalPendingUp.tile.viewStartRow = 30; | 1480 | finalPendingUp.tile.viewStartRow = 30; |
| 1461 | finalPendingUp.tile.requestedViewStartRow = 30; | ||
| 1462 | finalPendingUp.tile.paintScroll = () => {}; | 1481 | finalPendingUp.tile.paintScroll = () => {}; |
| 1463 | finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2)); | 1482 | finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2)); |
| 1464 | finalPendingUp.tile.canvas.dispatchEvent('pointermove', { | 1483 | finalPendingUp.tile.canvas.dispatchEvent('pointermove', { |
| @@ -1841,6 +1860,49 @@ async function verifySelectionShell(shell, html) { | |||
| 1841 | unzoomFeedbackTask?.fn(); | 1860 | unzoomFeedbackTask?.fn(); |
| 1842 | check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy'); | 1861 | check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy'); |
| 1843 | 1862 | ||
| 1863 | // The selection lane's two timer-driven hides, same hazard. | ||
| 1864 | const unavailableFocus = makeTile(); | ||
| 1865 | unavailableFocus.tile.selection = { | ||
| 1866 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 84, text: null, | ||
| 1867 | }; | ||
| 1868 | authorizeSelectionReply(unavailableFocus); | ||
| 1869 | unavailableFocus.setResult(84, 3, []); | ||
| 1870 | unavailableFocus.tile.onSelectionReply(); | ||
| 1871 | const unavailableFocusTimer = h.timers[unavailableFocus.tile.selectionFeedbackTimer - 1]; | ||
| 1872 | unavailableFocus.tile.copyButton.focus(); | ||
| 1873 | unavailableFocusTimer?.fn(); | ||
| 1874 | check('expiring selection feedback under focus returns focus to the IME', h.document.activeElement, h.elements.ime); | ||
| 1875 | |||
| 1876 | // The other side of the guard: retiring selection feedback onto a pending | ||
| 1877 | // OSC 52 leaves the control VISIBLE and actionable, so focus must stay | ||
| 1878 | // where the user put it. | ||
| 1879 | const visibleAfterFeedback = makeTile(); | ||
| 1880 | visibleAfterFeedback.tile.pendingClipboard = 'OSC52 still waiting'; | ||
| 1881 | visibleAfterFeedback.tile.clipboardVersion = 1; | ||
| 1882 | visibleAfterFeedback.tile.copyUiOwner = 'clipboard'; | ||
| 1883 | visibleAfterFeedback.tile.selection = { | ||
| 1884 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 86, text: null, | ||
| 1885 | }; | ||
| 1886 | authorizeSelectionReply(visibleAfterFeedback); | ||
| 1887 | visibleAfterFeedback.setResult(86, 3, []); | ||
| 1888 | visibleAfterFeedback.tile.onSelectionReply(); | ||
| 1889 | const visibleFeedbackTimer = h.timers[visibleAfterFeedback.tile.selectionFeedbackTimer - 1]; | ||
| 1890 | visibleAfterFeedback.tile.copyButton.focus(); | ||
| 1891 | visibleFeedbackTimer?.fn(); | ||
| 1892 | check('feedback expiring onto a still-visible retry keeps its focus', h.document.activeElement, visibleAfterFeedback.tile.copyButton); | ||
| 1893 | check('feedback expiring onto a still-visible retry stays actionable', `${visibleAfterFeedback.tile.copyButton.className}|${visibleAfterFeedback.tile.copyButton.textContent}`, 'copy-request on|Copy'); | ||
| 1894 | |||
| 1895 | const copiedFocus = makeTile(); | ||
| 1896 | copiedFocus.tile.selection = { | ||
| 1897 | anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 85, text: 'copied then hidden', | ||
| 1898 | }; | ||
| 1899 | h.navigator.clipboard = { writeText: () => Promise.resolve() }; | ||
| 1900 | await copiedFocus.tile.copySelection(); | ||
| 1901 | const copiedFocusTimer = h.timers.at(-1); | ||
| 1902 | copiedFocus.tile.copyButton.focus(); | ||
| 1903 | copiedFocusTimer.fn(); | ||
| 1904 | check('hiding the selection copy control under focus returns focus to the IME', h.document.activeElement, h.elements.ime); | ||
| 1905 | |||
| 1844 | const inFlightUi = makeTile(); | 1906 | const inFlightUi = makeTile(); |
| 1845 | inFlightUi.tile.pendingClipboard = 'in flight'; | 1907 | inFlightUi.tile.pendingClipboard = 'in flight'; |
| 1846 | inFlightUi.tile.clipboardVersion = 1; | 1908 | inFlightUi.tile.clipboardVersion = 1; |
| @@ -2056,7 +2118,6 @@ async function verifySelectionShell(shell, html) { | |||
| 2056 | const scrollExitKey = makeTile(); | 2118 | const scrollExitKey = makeTile(); |
| 2057 | scrollExitKey.tile.scrollPages = 1; | 2119 | scrollExitKey.tile.scrollPages = 1; |
| 2058 | scrollExitKey.tile.viewStartRow = 25; | 2120 | scrollExitKey.tile.viewStartRow = 25; |
| 2059 | scrollExitKey.tile.requestedViewStartRow = 25; | ||
| 2060 | scrollExitKey.tile.selection = authoritativeSelection('history selection'); | 2121 | scrollExitKey.tile.selection = authoritativeSelection('history selection'); |
| 2061 | h.setZoomedTile(scrollExitKey.tile); | 2122 | h.setZoomedTile(scrollExitKey.tile); |
| 2062 | const swallowedHistoryKey = keyEvent({ key: 'x' }); | 2123 | const swallowedHistoryKey = keyEvent({ key: 'x' }); |
| @@ -2093,6 +2154,21 @@ async function verifySelectionShell(shell, html) { | |||
| 2093 | check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null); | 2154 | check('real paste handler clears retained selection through sendText', pasteClears.tile.selection, null); |
| 2094 | check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1); | 2155 | check('real paste handler emits one unwrapped text frame', pasteClears.sent.length, 1); |
| 2095 | 2156 | ||
| 2157 | const pasteScroll = makeTile(); | ||
| 2158 | pasteScroll.tile.scrollPages = 1; | ||
| 2159 | pasteScroll.tile.viewStartRow = 25; | ||
| 2160 | pasteScroll.tile.core.mux_paste_begin = () => 0; | ||
| 2161 | pasteScroll.tile.core.mux_paste_end = () => 0; | ||
| 2162 | pasteScroll.tile.core.mux_text_encode = (len) => len; | ||
| 2163 | pasteScroll.tile.core.mux_output_len = () => 1; | ||
| 2164 | h.setZoomedTile(pasteScroll.tile); | ||
| 2165 | h.document.dispatchEvent('paste', { | ||
| 2166 | preventDefault() {}, | ||
| 2167 | clipboardData: { getData: () => 'pasted while paged back' }, | ||
| 2168 | }); | ||
| 2169 | check('paste returns the tile to live like every other input path', pasteScroll.tile.scrollPages, 0); | ||
| 2170 | check('paste out of history mode still delivers its content', pasteScroll.sent.length, 1); | ||
| 2171 | |||
| 2096 | const compositionClears = makeTile(); | 2172 | const compositionClears = makeTile(); |
| 2097 | compositionClears.tile.core.mux_text_encode = (len) => len; | 2173 | compositionClears.tile.core.mux_text_encode = (len) => len; |
| 2098 | compositionClears.tile.core.mux_output_len = () => 1; | 2174 | compositionClears.tile.core.mux_output_len = () => 1; |
| @@ -2205,6 +2281,18 @@ async function verifySelectionShell(shell, html) { | |||
| 2205 | check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); | 2281 | check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); |
| 2206 | check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime); | 2282 | check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime); |
| 2207 | 2283 | ||
| 2284 | // The same zoom gate onClipboardEffect and copyPendingClipboard carry: | ||
| 2285 | // only the tile the human is looking at may reach the system clipboard. | ||
| 2286 | const unzoomedSelectionCopy = makeTile(); | ||
| 2287 | unzoomedSelectionCopy.tile.zoomed = false; | ||
| 2288 | unzoomedSelectionCopy.tile.selection = authoritativeSelection('wall tiles do not copy'); | ||
| 2289 | const unzoomedSelectionWrites = []; | ||
| 2290 | h.navigator.clipboard = { writeText: (text) => { unzoomedSelectionWrites.push(text); return Promise.resolve(); } }; | ||
| 2291 | await unzoomedSelectionCopy.tile.copySelection(); | ||
| 2292 | check('an unzoomed tile writes no clipboard for its selection', unzoomedSelectionWrites.length, 0); | ||
| 2293 | check('an unzoomed selection copy claims no copy control', unzoomedSelectionCopy.tile.copyUiOwner, null); | ||
| 2294 | check('an unzoomed selection copy retains its text', unzoomedSelectionCopy.tile.selection?.text, 'wall tiles do not copy'); | ||
| 2295 | |||
| 2208 | const invalidatedCopy = makeTile(); | 2296 | const invalidatedCopy = makeTile(); |
| 2209 | invalidatedCopy.tile.selection = authoritativeSelection('old selection'); | 2297 | invalidatedCopy.tile.selection = authoritativeSelection('old selection'); |
| 2210 | h.setZoomedTile(invalidatedCopy.tile); | 2298 | h.setZoomedTile(invalidatedCopy.tile); |
| @@ -2244,7 +2332,6 @@ async function verifySelectionShell(shell, html) { | |||
| 2244 | 2332 | ||
| 2245 | const unzoomScroll = makeTile(); | 2333 | const unzoomScroll = makeTile(); |
| 2246 | unzoomScroll.tile.viewStartRow = 30; | 2334 | unzoomScroll.tile.viewStartRow = 30; |
| 2247 | unzoomScroll.tile.requestedViewStartRow = 30; | ||
| 2248 | unzoomScroll.tile.changeScrollPages(1); | 2335 | unzoomScroll.tile.changeScrollPages(1); |
| 2249 | const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1]; | 2336 | const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1]; |
| 2250 | h.setZoomedTile(unzoomScroll.tile); | 2337 | h.setZoomedTile(unzoomScroll.tile); |
| @@ -2288,7 +2375,6 @@ async function verifySelectionShell(shell, html) { | |||
| 2288 | reset.tile.lastPointerX = 20; | 2375 | reset.tile.lastPointerX = 20; |
| 2289 | reset.tile.lastPointerY = 30; | 2376 | reset.tile.lastPointerY = 30; |
| 2290 | reset.tile.viewStartRow = 30; | 2377 | reset.tile.viewStartRow = 30; |
| 2291 | reset.tile.requestedViewStartRow = 30; | ||
| 2292 | reset.tile.changeScrollPages(1); | 2378 | reset.tile.changeScrollPages(1); |
| 2293 | const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1]; | 2379 | const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1]; |
| 2294 | reset.tile.canvas.setPointerCapture(15); | 2380 | reset.tile.canvas.setPointerCapture(15); |