@leftoff/cli 0.9.17-beta.6 → 0.9.17-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/leftoff.js +451 -446
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +6 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +135 -6
- package/ui/app.js +131 -14
- package/ui/hosted-by.js +50 -0
- package/ui/live-view.js +1 -0
- package/ui/needs.js +66 -20
- package/ui/prs.js +53 -8
- package/ui/relay-client.js +96 -6
- package/ui/scroll-keep.js +247 -0
- package/ui/walk-pop.js +3 -3
package/ui/needs.js
CHANGED
|
@@ -434,9 +434,6 @@ export function prCardHtml(pr) {
|
|
|
434
434
|
: '<div class="need-pr">' + inner + '</div>'
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
export const GENERIC_REPLIES = ['Yes, go ahead', 'No', 'Show me the diff first']
|
|
438
|
-
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
439
|
-
|
|
440
437
|
// "Should I also add Google sign-in, or keep it email-only?" -> the two choices.
|
|
441
438
|
export function orChoices(question) {
|
|
442
439
|
const q = String(question || '').replace(/\s+/g, ' ').trim()
|
|
@@ -462,29 +459,78 @@ export function listChoices(question) {
|
|
|
462
459
|
return items.map(cap)
|
|
463
460
|
}
|
|
464
461
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
462
|
+
// 2026-10-29 (beta feedback: "a lot of the time the suggestions on Needs you are
|
|
463
|
+
// bad"): a suggestion is either one of the question's own choices, word for word,
|
|
464
|
+
// or a plain answer to a yes/no question. Anything else gets no suggestions: the
|
|
465
|
+
// old fallback ("Yes, go ahead" / "No" / "Show me the diff first") showed up under
|
|
466
|
+
// questions that need a specific answer ("What should the timeout be?"), and
|
|
467
|
+
// "Show me the diff first" was added to every either/or question.
|
|
468
|
+
export const GENERIC_REPLIES = ['Yes, go ahead', 'No'] // a yes/no question's answers (the name is kept for older imports)
|
|
469
|
+
export const YES_NO = GENERIC_REPLIES
|
|
470
|
+
export const REPLY_MAX_CHARS = 100 // a suggestion longer than this isn't a quick reply
|
|
471
|
+
export const REPLY_MAX = 6 // at most this many (a numbered menu can have six)
|
|
472
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
473
|
+
|
|
474
|
+
// The sentence that asks: the last one ending in "?" (markup off, one space).
|
|
475
|
+
export function askingSentence(question) {
|
|
476
|
+
const parts = String(question || '').replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+(?=\S)/)
|
|
477
|
+
return (parts.filter((p) => /\?\s*$/.test(p)).pop() || '').trim()
|
|
478
|
+
}
|
|
479
|
+
// What kind of answer a question wants: 'yesno' (Should I…? Want me to…? Does
|
|
480
|
+
// this plan look good? Ready to merge? (y/n)), 'open' (What / Which / How… with
|
|
481
|
+
// no choices to pick from), or 'unknown'. Either/or questions are neither: their
|
|
482
|
+
// choices are the answers (orChoices / listChoices).
|
|
483
|
+
const OPEN_RE = /^(?:so,?\s+|and\s+|also,?\s+|ok(?:ay)?,?\s+)?(?:what|which|how|where|when|who|whom|whose|why|any\s+(?:idea|preference|thoughts)|anything\s+else|is\s+there\s+anything)\b/i
|
|
484
|
+
const YESNO_START = /^(?:should|shall|do|does|did|is|are|was|were|can|could|may|might|would|will|want|ready|ok(?:ay)?|sound|sounds|look|looks|happy|good|proceed|continue|go\s+ahead|all\s+good|fine|alright|cool|safe|mind\s+if)\b/i
|
|
485
|
+
const YESNO_ANY = /\b(?:want me to|should i|shall i|ok(?:ay)? (?:if i|to)|good to go|go ahead|look(?:s)? good|sound(?:s)? good|look(?:s)? right|approve|proceed|ready (?:to|for)|(?:do|would) you like me to)\b/i
|
|
486
|
+
export function questionShape(question) {
|
|
487
|
+
const raw = String(question || '')
|
|
488
|
+
if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b|\[y\/n\]/i.test(raw)) return 'yesno'
|
|
489
|
+
const q = askingSentence(raw)
|
|
490
|
+
if (!q) return 'unknown'
|
|
491
|
+
// the clause that asks ("If the tests pass, want me to push?" -> "want me to push?")
|
|
492
|
+
const clause = q.split(/,\s+|;\s+|\s[—–-]\s/).pop().trim()
|
|
493
|
+
if (OPEN_RE.test(q) || OPEN_RE.test(clause)) return 'open'
|
|
494
|
+
if (/\bor\b/i.test(clause)) return 'unknown' // either/or: yes isn't an answer
|
|
495
|
+
if (YESNO_START.test(q) || YESNO_START.test(clause) || YESNO_ANY.test(clause)) return 'yesno'
|
|
496
|
+
return 'unknown'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Kept to quick-reply size, duplicates out, at most REPLY_MAX.
|
|
500
|
+
function tidyReplies(list) {
|
|
501
|
+
const out = []
|
|
502
|
+
for (const t of list || []) {
|
|
503
|
+
const v = String(t == null ? '' : t).replace(/\s+/g, ' ').trim()
|
|
504
|
+
if (!v || v.length > REPLY_MAX_CHARS || out.includes(v)) continue
|
|
505
|
+
out.push(v)
|
|
506
|
+
}
|
|
507
|
+
return out.slice(0, REPLY_MAX)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The suggestions for a waiting question. A question's own choices first, word
|
|
511
|
+
// for word (AskUserQuestion's options, "…: A, B, or C?", a numbered menu the
|
|
512
|
+
// question follows, "X, or Y?"); then Yes / No for a yes/no question; else none.
|
|
513
|
+
// A pick fills the answer box; you send it.
|
|
468
514
|
export function quickReplies(n) {
|
|
469
515
|
if (!n) return []
|
|
516
|
+
let question = ''
|
|
470
517
|
if (n.kind === 'question' && n.ask) {
|
|
471
518
|
const qs = n.ask.questions || []
|
|
472
519
|
if (qs.length !== 1) return []
|
|
473
520
|
const q = qs[0]
|
|
474
|
-
if (q.options && q.options.length) return q.multiSelect ? [] : q.options.map((o) => o.label).slice(0, 4)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
return GENERIC_REPLIES
|
|
521
|
+
if (q.options && q.options.length) return q.multiSelect ? [] : tidyReplies(q.options.map((o) => o.label)).slice(0, 4)
|
|
522
|
+
question = q.question
|
|
523
|
+
} else if (n.kind === 'reply') {
|
|
524
|
+
question = n.question
|
|
525
|
+
} else return []
|
|
526
|
+
const inl = listChoices(question) // the question's own list (a peer's older daemon may still send a list's items)
|
|
527
|
+
if (inl.length) return tidyReplies(inl)
|
|
528
|
+
const listed = n.kind === 'reply' ? ((n.context && n.context.options) || []) : []
|
|
529
|
+
if (listed.length >= 2) return tidyReplies(listed)
|
|
530
|
+
const alt = orChoices(question)
|
|
531
|
+
if (alt.length) return tidyReplies(alt)
|
|
532
|
+
if (questionShape(question) === 'yesno') return /\(\s*y\s*\/\s*n\s*\)|\[y\/n\]/i.test(String(question || '')) ? ['Yes', 'No'] : YES_NO.slice()
|
|
533
|
+
return []
|
|
488
534
|
}
|
|
489
535
|
|
|
490
536
|
// The draft a card's quick replies fill (the Reply box, or a question's "Other").
|
package/ui/prs.js
CHANGED
|
@@ -28,15 +28,41 @@ export function checksHtml(ck, { compact = false } = {}) {
|
|
|
28
28
|
return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
|
|
29
29
|
}
|
|
30
30
|
const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
|
|
31
|
+
// 2026-10-29 State by shape plus the word (never colour alone): open ⟟ a PR glyph, draft one with a
|
|
32
|
+
// dashed head, merged the merge glyph, closed the PR glyph with an ✕; not checked a dashed ring.
|
|
33
|
+
const svg = (d) => '<svg class="pr-st-ic" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
|
|
34
|
+
export const STATE_IC = {
|
|
35
|
+
open: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/>'),
|
|
36
|
+
draft: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><path d="M6 9v12"/>'),
|
|
37
|
+
merged: svg('<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>'),
|
|
38
|
+
closed: svg('<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/><path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>'),
|
|
39
|
+
unchecked: svg('<circle cx="12" cy="12" r="8" stroke-dasharray="3 3.3"/>'),
|
|
40
|
+
}
|
|
31
41
|
export function stateHtml(state) {
|
|
32
42
|
if (!state) return ''
|
|
33
|
-
return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
|
|
43
|
+
return '<span class="pr-state ' + esc(state) + '">' + (STATE_IC[state] || '') + esc(STATE[state] || state) + '</span>'
|
|
44
|
+
}
|
|
45
|
+
// 2026-10-29 A short ref that couldn't be checked: why, for its data-tip.
|
|
46
|
+
export function uncheckedTip(p) {
|
|
47
|
+
const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
|
|
48
|
+
const name = String(what).split('#')[0]
|
|
49
|
+
if (p.unchecked === 'ambiguous') return 'Named in this session, but “' + name + '” is more than one GitHub repo here' + (p.options && p.options.length ? ' (' + p.options.join(', ') + ')' : '') + ', so it wasn’t checked. Link the right one below.'
|
|
50
|
+
if (p.unchecked === 'unknown') return 'Named in this session, but Leftoff doesn’t know which GitHub repo “' + name + '” is yet (this session has no GitHub remote), so it wasn’t checked. It links once a session in that repo runs on one of your machines, or link it below.'
|
|
51
|
+
if (p.unchecked === 'no_gh') return 'Named in this session (' + (p.repo || name) + '). No machine with gh signed in has answered yet, so it wasn’t checked.'
|
|
52
|
+
if (p.unchecked === 'failed') return 'Named in this session (' + (p.repo || name) + '), but gh couldn’t read it just now, so it wasn’t checked. It tries again in a few minutes.'
|
|
53
|
+
return 'Named in this session (' + (p.repo || name) + '). Checking it with gh…'
|
|
54
|
+
}
|
|
55
|
+
export function uncheckedHtml(p) {
|
|
56
|
+
const what = p.label || ((p.repo ? p.repo.split('/')[1] : p.name || '') + '#' + p.number)
|
|
57
|
+
const word = p.unchecked === 'checking' ? 'checking' : 'not checked'
|
|
58
|
+
const tip = uncheckedTip(p)
|
|
59
|
+
return '<span class="pr-unchecked" tabindex="0" role="note" data-tip="' + esc(tip) + '" aria-label="' + esc(what + ', ' + word + '. ' + tip) + '">' + STATE_IC.unchecked + '<span class="pr-un-ref">' + esc(what) + '</span><span class="pr-un-word"> · ' + esc(word) + '</span></span>'
|
|
34
60
|
}
|
|
35
61
|
const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
|
|
36
62
|
|
|
37
63
|
// A session card's PRs: one compact line each (#, title, state, checks), three at most.
|
|
38
64
|
export function cardPrsHtml(prs) {
|
|
39
|
-
const list = (prs || []).filter((p) => p && p.number)
|
|
65
|
+
const list = (prs || []).filter((p) => p && p.number && !p.unchecked)
|
|
40
66
|
if (!list.length) return ''
|
|
41
67
|
const shown = list.slice(0, 3)
|
|
42
68
|
const more = list.length - shown.length
|
|
@@ -80,8 +106,12 @@ export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
|
80
106
|
|
|
81
107
|
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
82
108
|
export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
// 2026-10-29 + prsMore: closed PRs (a card leaves them out) and refs that couldn't be checked
|
|
110
|
+
const more = (s.prsMore || []).filter((p) => p && p.number)
|
|
111
|
+
const list = (s.prs || []).filter((p) => p && p.number).concat(more.filter((p) => !p.unchecked && p.url && !(s.prs || []).some((x) => x && x.url === p.url)))
|
|
112
|
+
const unchecked = more.filter((p) => p.unchecked)
|
|
113
|
+
const n = list.length + unchecked.length
|
|
114
|
+
let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
|
|
85
115
|
if (list.length) {
|
|
86
116
|
h += '<div class="prlist">' + list.map((p) => {
|
|
87
117
|
// 0.9.8: tap the title (or More…) for the same details as the PRs page, in place; GitHub ↗ still goes out
|
|
@@ -93,13 +123,28 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
93
123
|
(isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
94
124
|
prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
|
|
95
125
|
}).join('') + '</div>'
|
|
96
|
-
}
|
|
97
|
-
if (
|
|
126
|
+
}
|
|
127
|
+
if (unchecked.length) h += '<div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div>'
|
|
128
|
+
if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
|
|
129
|
+
(mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
|
|
130
|
+
if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
98
131
|
return h
|
|
99
132
|
}
|
|
133
|
+
// 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
|
|
134
|
+
const PR_HOW = 'PRs link on their own from gh pr create, a PR link or number in your messages or the title, gh pr checkout, a push to a PR’s branch, or the branch itself.'
|
|
135
|
+
const prInputId = (id) => 'prref-' + String(id || '').replace(/[^A-Za-z0-9_-]/g, '_')
|
|
136
|
+
const DEFAULTS = ['main', 'master', 'trunk', 'develop']
|
|
137
|
+
export function noPrWhy(s) {
|
|
138
|
+
const r = (s && s.repo) || {}
|
|
139
|
+
if (!r.root && !r.name && !(s && s.repoName && s.repoName !== 'no-repo')) return 'not in a git repo'
|
|
140
|
+
if (r.branch && DEFAULTS.includes(r.branch)) return 'it ran on ' + r.branch + ' and named no PR'
|
|
141
|
+
if (r.branch) return 'no open PR for ' + r.branch
|
|
142
|
+
return ''
|
|
143
|
+
}
|
|
100
144
|
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
101
|
-
|
|
102
|
-
const
|
|
145
|
+
// 2026-10-28: + gh (worked on it with gh), pushed, title, commits, ghview.
|
|
146
|
+
export const WHY = { named: 'named in this session', suggested: 'named in this session', created: 'created here', gh: 'worked on here', pushed: 'pushed to its branch', you: 'you mentioned it', title: 'named in the title', commits: 'has this session’s commits', branch: 'same branch', ghview: 'looked up here', mentioned: 'same branch', manual: 'linked by you' }
|
|
147
|
+
const WHY_ORDER = ['manual', 'created', 'gh', 'pushed', 'you', 'suggested', 'title', 'commits', 'branch', 'ghview', 'named', 'mentioned']
|
|
103
148
|
export function whyText(r) { const v = r && (r.why || (Array.isArray(r.via) ? WHY_ORDER.find((k) => r.via.includes(k)) : r.via)); return v ? WHY[v] || '' : '' }
|
|
104
149
|
|
|
105
150
|
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|
package/ui/relay-client.js
CHANGED
|
@@ -58,7 +58,30 @@ async function loadDirect() {
|
|
|
58
58
|
const INIT_WAIT_MS = 1200 // how long the first paint waits for this Mac's own answer
|
|
59
59
|
const RELOAD_KEY = 'leftoff.direct.reloaded'
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// 0.9.18: a pick from the reconnecting screen ("Use laptop instead") lasts this tab only
|
|
62
|
+
const TEMP_KEY = 'leftoff.relay.mac.temp', REJECT_KEY = 'leftoff.relay.rejected', REJECT_MS = 10 * 60 * 1000
|
|
63
|
+
function pickedMac() { try { return localStorage.getItem(MAC_KEY) || sessionStorage.getItem(TEMP_KEY) } catch { return null } }
|
|
64
|
+
function tempPick(mac) { try { sessionStorage.setItem(TEMP_KEY, mac) } catch {} }
|
|
65
|
+
// Machines that just refused this browser's key (unknown_device / not_ready): skipped for a while,
|
|
66
|
+
// so the board doesn't keep going back to a host that can't open it (today's "Reconnecting" loop).
|
|
67
|
+
function rejected(now = Date.now()) {
|
|
68
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); return Object.keys(o).filter((m) => now - o[m] < REJECT_MS) } catch { return [] }
|
|
69
|
+
}
|
|
70
|
+
function reject(mac, now = Date.now()) {
|
|
71
|
+
if (!mac) return
|
|
72
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); o[mac] = now; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } catch {}
|
|
73
|
+
}
|
|
74
|
+
function unreject(mac) { try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); if (o[mac]) { delete o[mac]; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } } catch {} }
|
|
75
|
+
// Which machine this browser uses: your own pick while it's online, else the relay's host
|
|
76
|
+
// (relay/src/hosting.js), else the last one that answered, else any online one that hasn't
|
|
77
|
+
// just refused this browser. Pure, unit-tested.
|
|
78
|
+
export function pickMac({ picked = null, online = [], host = null, rejected = [], good = null } = {}) {
|
|
79
|
+
const ok = (m) => m && online.includes(m) && !rejected.includes(m)
|
|
80
|
+
if (picked && online.includes(picked)) return picked
|
|
81
|
+
if (ok(host)) return host
|
|
82
|
+
if (ok(good)) return good
|
|
83
|
+
return online.find((m) => !rejected.includes(m)) || host || null
|
|
84
|
+
}
|
|
62
85
|
function remember(mac) {
|
|
63
86
|
try { if (mac) localStorage.setItem(MAC_KEY, mac); else localStorage.removeItem(MAC_KEY) } catch {}
|
|
64
87
|
}
|
|
@@ -73,8 +96,7 @@ async function refreshStatus() {
|
|
|
73
96
|
const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
|
|
74
97
|
// on this Mac's direct path the board stays on this Mac, whatever the relay sees
|
|
75
98
|
if (!onDirect()) {
|
|
76
|
-
|
|
77
|
-
relay.mac = want && online.includes(want) ? want : relay.status.default
|
|
99
|
+
relay.mac = pickMac({ picked: pickedMac(), online, host: relay.status.default, rejected: rejected(), good: lastGood() })
|
|
78
100
|
cookieMac(relay.mac)
|
|
79
101
|
}
|
|
80
102
|
return relay.status
|
|
@@ -252,8 +274,27 @@ async function viaRelay(body, init, retried) {
|
|
|
252
274
|
const gone = relay.mac
|
|
253
275
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
254
276
|
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
255
|
-
if (res.status === 200) {
|
|
277
|
+
if (res.status === 200) {
|
|
278
|
+
good(gone); unreject(gone)
|
|
279
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
280
|
+
// 0.9.18: the host changed (made elsewhere, or a preferred machine came back): follow it
|
|
281
|
+
const host = res.headers.get('x-leftoff-host')
|
|
282
|
+
if (host && host !== relay.mac && relay.mac === gone && !pickedMac() && !onDirect() && /^[\w.-]{1,64}$/.test(host)) followHost(host)
|
|
283
|
+
return out
|
|
284
|
+
}
|
|
256
285
|
const j = await res.clone().json().catch(() => ({}))
|
|
286
|
+
if ((j.code === 'unknown_device' || j.code === 'not_ready') && !retried) {
|
|
287
|
+
// 0.9.18: this machine can't open this browser's key (a sandbox outside your synced
|
|
288
|
+
// folder, a Mac still starting): another online machine that can takes over
|
|
289
|
+
reject(gone)
|
|
290
|
+
await refreshStatus().catch(() => {})
|
|
291
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
292
|
+
}
|
|
293
|
+
if (res.status === 504 && j.code === 'timeout' && !retried) {
|
|
294
|
+
// the host stopped answering (a closed lid before the relay notices): ask who hosts now
|
|
295
|
+
await refreshStatus().catch(() => {})
|
|
296
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
297
|
+
}
|
|
257
298
|
if (authVerdict(res.status, j.code) !== 'ok') {
|
|
258
299
|
// 0.9.17: the Mac that took over while yours restarted may not know this browser:
|
|
259
300
|
// stay with the one that does, and look again (never forget the key on this)
|
|
@@ -277,6 +318,16 @@ async function viaRelay(body, init, retried) {
|
|
|
277
318
|
return res
|
|
278
319
|
}
|
|
279
320
|
|
|
321
|
+
// The relay says another machine hosts now. Once per few seconds at most; the board
|
|
322
|
+
// hears leftoff-relay { switched } as it does after a failover.
|
|
323
|
+
let followedAt = 0
|
|
324
|
+
function followHost(host) {
|
|
325
|
+
if (Date.now() - followedAt < 5000) return
|
|
326
|
+
followedAt = Date.now()
|
|
327
|
+
relay.mac = host; cookieMac(host)
|
|
328
|
+
try { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: host, switched: true, followed: true } })) } catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
280
331
|
function patchedFetch(input, init) {
|
|
281
332
|
try {
|
|
282
333
|
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
|
|
@@ -433,7 +484,40 @@ function trouble(code) {
|
|
|
433
484
|
}
|
|
434
485
|
function reconnecting() {
|
|
435
486
|
if (document.querySelector('.relay-screen')) return
|
|
436
|
-
screen('Reconnecting to your board…',
|
|
487
|
+
screen('Reconnecting to your board…', reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rejected() }))
|
|
488
|
+
wirePicker()
|
|
489
|
+
}
|
|
490
|
+
// 0.9.18: the reconnecting screen says where each machine is (shape + word) and, when the
|
|
491
|
+
// host is out but another machine is online, that it's switching; with several online, a
|
|
492
|
+
// "Use <name> instead" button each (this tab only). Pure, unit-tested.
|
|
493
|
+
export function reconnectHtml({ status = null, mac = null, rejected = [] } = {}) {
|
|
494
|
+
const macs = (status && status.macs) || []
|
|
495
|
+
const online = macs.filter((m) => m.online).map((m) => m.machine)
|
|
496
|
+
const name = mac || 'Your Mac'
|
|
497
|
+
const hostOut = mac && (!online.includes(mac) || rejected.includes(mac))
|
|
498
|
+
const others = online.filter((m) => m !== mac && !rejected.includes(m))
|
|
499
|
+
const target = pickMac({ online: others, host: status && status.default, rejected })
|
|
500
|
+
let h = '<p class="relay-wait" role="status"><span class="relay-dot" aria-hidden="true"></span>'
|
|
501
|
+
if (hostOut && target) h += esc(name) + (online.includes(mac) ? ' can’t open this browser’s key.' : ' is offline.') + ' ' + esc(target) + ' is online: switching to it…</p>'
|
|
502
|
+
else h += esc(name) + ' may be restarting. This takes a few seconds.</p>'
|
|
503
|
+
if (macs.length > 1) {
|
|
504
|
+
h += '<ul class="relay-hosts" aria-label="Your machines">'
|
|
505
|
+
for (const m of macs) {
|
|
506
|
+
const st = m.online ? (rejected.includes(m.machine) ? ['◐', 'can’t open this browser’s key'] : ['●', 'online']) : m.state === 'reconnecting' ? ['◌', 'reconnecting'] : ['○', 'offline']
|
|
507
|
+
h += '<li class="relay-host ' + (m.online ? 'on' : 'off') + '"><span class="relay-host-shape" aria-hidden="true">' + st[0] + '</span><b>' + esc(m.machine) + '</b><span class="relay-host-word">' + st[1] + (m.machine === mac ? ' · was hosting' : '') + '</span>'
|
|
508
|
+
if (others.length > 1 && others.includes(m.machine)) h += '<button type="button" class="btn relay-use" data-mac="' + esc(m.machine) + '">Use ' + esc(m.machine) + ' instead</button>'
|
|
509
|
+
h += '</li>'
|
|
510
|
+
}
|
|
511
|
+
h += '</ul>'
|
|
512
|
+
}
|
|
513
|
+
return h
|
|
514
|
+
}
|
|
515
|
+
export function wirePicker() {
|
|
516
|
+
document.querySelectorAll('.relay-use').forEach((b) => b.addEventListener('click', () => {
|
|
517
|
+
const m = b.getAttribute('data-mac')
|
|
518
|
+
if (!m) return
|
|
519
|
+
stopTrouble(); tempPick(m); unreject(m); good(m); cookieMac(m); location.reload()
|
|
520
|
+
}))
|
|
437
521
|
}
|
|
438
522
|
// One sealed ping per candidate Mac (the one that last answered first): 'ok' (with
|
|
439
523
|
// the Mac), 'revoked', or 'retry'.
|
|
@@ -443,7 +527,10 @@ async function pingMacs() {
|
|
|
443
527
|
if (res0 && res0.status === 401) { const j = await res0.json().catch(() => ({})); return { verdict: authVerdict(401, j.code), code: j.code || 'not_paired' } }
|
|
444
528
|
if (res0 && res0.ok) { st = await res0.json().catch(() => null); if (st) relay.status = st }
|
|
445
529
|
const online = st ? st.macs.filter((m) => m.online).map((m) => m.machine) : []
|
|
446
|
-
|
|
530
|
+
// 0.9.18: the relay's host first, machines that just refused this browser last
|
|
531
|
+
const rej = rejected()
|
|
532
|
+
const order = [...new Set([st && st.default, lastGood(), relay.mac, ...online].filter((m) => m && online.includes(m)))].sort((a, b) => rej.includes(a) - rej.includes(b))
|
|
533
|
+
if (document.querySelector('.relay-screen') && !lostShown && !notPairedShown) { const w = document.querySelector('.relay-screen .relay-card'); if (w) { w.innerHTML = '<div class="relay-logo">Leftoff</div><h1>Reconnecting to your board…</h1>' + reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rej }); wirePicker() } }
|
|
447
534
|
let last = { verdict: 'retry', code: online.length ? 'unknown_device' : 'mac_offline' }
|
|
448
535
|
for (const machine of order) {
|
|
449
536
|
try {
|
|
@@ -503,6 +590,9 @@ export const leftoffRelay = {
|
|
|
503
590
|
online(mac) { return !!mac && onlineMacs().includes(String(mac)) },
|
|
504
591
|
// Switch Macs: the board reloads from the other one.
|
|
505
592
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
593
|
+
// 0.9.18 Hosted by: the user's host changed (Settings): drop this browser's own pick and follow it.
|
|
594
|
+
useHost(mac) { remember(null); try { sessionStorage.removeItem(TEMP_KEY) } catch {} if (mac && mac !== relay.mac) { unreject(mac); cookieMac(mac); location.reload() } },
|
|
595
|
+
get pinned() { return pickedMac() },
|
|
506
596
|
// This browser revoked itself (0.6.6).
|
|
507
597
|
unpaired() { notPaired('self') },
|
|
508
598
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// 0.9.18 Scroll, focus and open <details> kept through every redraw (Joel, Oct 28: "the right
|
|
2
|
+
// sidebar ... is doing the scroll jump thing too. It seems to be a bug that's in a lot of places").
|
|
3
|
+
//
|
|
4
|
+
// A live update (session.updated over SSE -> soon() -> render()) repaints whole pages with
|
|
5
|
+
// innerHTML (paintSession, the board, Settings, PRs ...) and polls repaint parts of them. A box
|
|
6
|
+
// that is rebuilt starts at scroll 0, and one taken out and put back in is reset to 0, so
|
|
7
|
+
// whatever you had scrolled jumped to the top. Rather than patch each panel, this watches the
|
|
8
|
+
// page itself: it records where every scroll box is as you scroll (keyed by a stable path:
|
|
9
|
+
// an id or data-scroll-key, then tag.class:index below it), and right after any DOM change
|
|
10
|
+
// (a MutationObserver, which runs before the browser paints, so there's no visible jump) puts a
|
|
11
|
+
// box that came back at 0 where it was. It never moves a box the app itself positioned since
|
|
12
|
+
// (its own scrollTop / scrollTo / scrollIntoView: the chat's stick-to-bottom, a jump to a file),
|
|
13
|
+
// never carries a place over to another page (records belong to the route they were made on),
|
|
14
|
+
// and only ever moves a box away from 0, never to the top. A box whose content came back
|
|
15
|
+
// shorter for a moment (a placeholder, a late image) is put back as the content grows.
|
|
16
|
+
// Focus and the caret in a field come back when a redraw removed the focused element; a
|
|
17
|
+
// <details> you opened or closed stays that way.
|
|
18
|
+
|
|
19
|
+
// (whole class names that come and go with state; is-* and has-* too)
|
|
20
|
+
const STATE = /^(?:(?:is|has)-.*|on|off|cur|current|open|shut|closed|active|sel|selected|hide|hidden|show|shown|stuck|fold|folded|live|busy|err|warn|dim|faint|new|seen|away|up|down|sheet-up|collapsed|wide|narrow|empty|loading|ready|copied|pinned|exited|done)$/
|
|
21
|
+
|
|
22
|
+
// One step of an element's path: its own key, or tag + first non-state class + index among like siblings
|
|
23
|
+
export function scrollSeg(el) {
|
|
24
|
+
const k = el.getAttribute && el.getAttribute('data-scroll-key')
|
|
25
|
+
if (k) return '[' + k + ']'
|
|
26
|
+
if (el.id) return '#' + el.id
|
|
27
|
+
const tag = String(el.tagName || '').toLowerCase()
|
|
28
|
+
let cls = ''
|
|
29
|
+
for (const c of el.classList || []) if (!STATE.test(c)) { cls = c; break }
|
|
30
|
+
let i = 0
|
|
31
|
+
for (let p = el.previousElementSibling; p; p = p.previousElementSibling) if (p.tagName === el.tagName && (!cls || (p.classList && p.classList.contains(cls)))) i++
|
|
32
|
+
return tag + (cls ? '.' + cls : '') + (i ? ':' + i : '')
|
|
33
|
+
}
|
|
34
|
+
// The path up to the nearest id / data-scroll-key (or the body)
|
|
35
|
+
export function scrollKey(el) {
|
|
36
|
+
const parts = []
|
|
37
|
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
|
|
38
|
+
const tag = String(e.tagName || '').toLowerCase()
|
|
39
|
+
if (tag === 'html') { parts.unshift('html'); break }
|
|
40
|
+
parts.unshift(scrollSeg(e))
|
|
41
|
+
if (tag === 'body' || e.id || (e.getAttribute && e.getAttribute('data-scroll-key'))) break
|
|
42
|
+
}
|
|
43
|
+
return parts.join('>')
|
|
44
|
+
}
|
|
45
|
+
// Back from a path to the element in the page now (null when it isn't there)
|
|
46
|
+
export function findByKey(doc, key) {
|
|
47
|
+
const parts = String(key || '').split('>')
|
|
48
|
+
if (!parts[0]) return null
|
|
49
|
+
let el
|
|
50
|
+
const a = parts[0]
|
|
51
|
+
if (a === 'html') el = doc.documentElement
|
|
52
|
+
else if (a === 'body') el = doc.body
|
|
53
|
+
else if (a[0] === '#') el = doc.getElementById(a.slice(1))
|
|
54
|
+
else if (a[0] === '[') { const v = a.slice(1, -1); el = [...doc.querySelectorAll('[data-scroll-key]')].find((e) => e.getAttribute('data-scroll-key') === v) || null }
|
|
55
|
+
else return null
|
|
56
|
+
for (let i = 1; el && i < parts.length; i++) {
|
|
57
|
+
let next = null
|
|
58
|
+
for (const c of el.children) if (scrollSeg(c) === parts[i]) { next = c; break }
|
|
59
|
+
el = next
|
|
60
|
+
}
|
|
61
|
+
return el || null
|
|
62
|
+
}
|
|
63
|
+
// Whether to put a box back: it came back at 0 (rebuilt or put back in) where it had been further on,
|
|
64
|
+
// and nothing (the app, you) moved it since the record. -> { top, left } to set, or null
|
|
65
|
+
export function restoreFor(rec, cur, { wroteAt = 0 } = {}) {
|
|
66
|
+
if (!rec || wroteAt > rec.at) return null
|
|
67
|
+
const t = rec.top > 1 && cur.top === 0 && Math.abs(cur.top - rec.top) > 1
|
|
68
|
+
const l = rec.left > 1 && cur.left === 0 && Math.abs(cur.left - rec.left) > 1
|
|
69
|
+
if (!t && !l) return null
|
|
70
|
+
return { top: t ? rec.top : null, left: l ? rec.left : null }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createScrollKeep({ win = window, doc = document, now = () => performance.now(), maxRecords = 80 } = {}) {
|
|
74
|
+
const recs = new Map() // route|key -> { top, left, at, el: WeakRef }
|
|
75
|
+
const wrote = new WeakMap() // element -> when the app itself last positioned it
|
|
76
|
+
const pending = new Map() // element -> { top, left, until, set } a restore the content wasn't tall enough for yet
|
|
77
|
+
const details = new Map() // route|key -> open, the <details> you opened or closed
|
|
78
|
+
let lastInput = -1e9, appUntil = -1e9, quietUntil = -1e9, restoring = 0
|
|
79
|
+
let focus = null // { el, key, start, end, dir } what has focus (kept when a redraw removed it)
|
|
80
|
+
const stats = { restored: 0, refocused: 0, reopened: 0 }
|
|
81
|
+
const route = () => win.location.pathname + win.location.hash
|
|
82
|
+
const se = () => doc.scrollingElement || doc.documentElement
|
|
83
|
+
const W = typeof WeakRef === 'function' ? (x) => new WeakRef(x) : (x) => ({ deref: () => x })
|
|
84
|
+
const keyFor = (el) => el === se() ? 'doc' : scrollKey(el)
|
|
85
|
+
const elFor = (key) => key === 'doc' ? se() : findByKey(doc, key)
|
|
86
|
+
const user = () => now() - lastInput < 800
|
|
87
|
+
|
|
88
|
+
function record(el, t = now()) {
|
|
89
|
+
if (!el || !el.isConnected) return
|
|
90
|
+
const k = route() + '|' + keyFor(el)
|
|
91
|
+
recs.delete(k)
|
|
92
|
+
recs.set(k, { top: el.scrollTop, left: el.scrollLeft, at: t, el: W(el) })
|
|
93
|
+
if (recs.size > maxRecords) recs.delete(recs.keys().next().value)
|
|
94
|
+
}
|
|
95
|
+
function set(el, top, left) {
|
|
96
|
+
restoring++
|
|
97
|
+
try { if (top != null && el.scrollTop !== top) el.scrollTop = top; if (left != null && el.scrollLeft !== left) el.scrollLeft = left } finally { restoring-- }
|
|
98
|
+
quietUntil = now() + 250
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -- as you scroll
|
|
102
|
+
function onScroll(e) {
|
|
103
|
+
const el = e.target === doc || e.target === win ? se() : e.target
|
|
104
|
+
if (!el || el.nodeType !== 1 || !el.isConnected) return
|
|
105
|
+
const p = pending.get(el)
|
|
106
|
+
const t = now()
|
|
107
|
+
if (p) {
|
|
108
|
+
// still short of where it was and nobody moved it (our own set, or the browser clamping it): keep waiting
|
|
109
|
+
if (!user() && t < p.until && (el.scrollTop === p.set || el.scrollTop < (p.top || 0))) return
|
|
110
|
+
pending.delete(el)
|
|
111
|
+
}
|
|
112
|
+
const k = route() + '|' + keyFor(el)
|
|
113
|
+
const r = recs.get(k)
|
|
114
|
+
const app = t - (wrote.get(el) || -1e9) < 1500 || t < appUntil
|
|
115
|
+
if (r && !user() && !app && el.scrollTop < r.top - 1 && t - r.at > 0) {
|
|
116
|
+
// moved up with no hand on it: content came back shorter (clamped) or the box was reset; put it back as it grows
|
|
117
|
+
const max = el.scrollHeight - el.clientHeight
|
|
118
|
+
if (el.scrollTop === 0 || el.scrollTop >= max - 1) { pending.set(el, { top: r.top, left: null, until: t + 2000, set: el.scrollTop }); tick(); return }
|
|
119
|
+
}
|
|
120
|
+
record(el, t)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -- the app positioning a box itself (never undone)
|
|
124
|
+
function mark(el) { if (!restoring && el && el.nodeType === 1) { wrote.set(el, now()); pending.delete(el) } }
|
|
125
|
+
function patchProto() {
|
|
126
|
+
const E = win.Element && win.Element.prototype
|
|
127
|
+
if (!E || E.__scrollKeep) return
|
|
128
|
+
E.__scrollKeep = true
|
|
129
|
+
for (const prop of ['scrollTop', 'scrollLeft']) {
|
|
130
|
+
const d = Object.getOwnPropertyDescriptor(E, prop)
|
|
131
|
+
if (!d || !d.set) continue
|
|
132
|
+
Object.defineProperty(E, prop, { configurable: true, enumerable: d.enumerable, get: d.get, set(v) { d.set.call(this, v); if (!restoring) { mark(this); record(this) } } })
|
|
133
|
+
}
|
|
134
|
+
for (const m of ['scrollTo', 'scroll', 'scrollBy']) {
|
|
135
|
+
const f = E[m]; if (typeof f !== 'function') continue
|
|
136
|
+
E[m] = function (...a) { const r = f.apply(this, a); if (!restoring) { mark(this); if (a[0] && a[0].behavior === 'smooth') appUntil = now() + 1500; record(this) } return r }
|
|
137
|
+
}
|
|
138
|
+
const siv = E.scrollIntoView
|
|
139
|
+
if (typeof siv === 'function') E.scrollIntoView = function (...a) {
|
|
140
|
+
const r = siv.apply(this, a)
|
|
141
|
+
if (!restoring) { appUntil = now() + (a[0] && a[0].behavior === 'smooth' ? 1500 : 300); for (let p = this.parentElement; p; p = p.parentElement) { mark(p); record(p) } }
|
|
142
|
+
return r
|
|
143
|
+
}
|
|
144
|
+
for (const m of ['scrollTo', 'scroll', 'scrollBy']) {
|
|
145
|
+
const f = win[m]; if (typeof f !== 'function') continue
|
|
146
|
+
win[m] = function (...a) { const r = f.apply(win, a); if (!restoring) { mark(se()); if (a[0] && a[0].behavior === 'smooth') appUntil = now() + 1500; record(se()) } return r }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -- focus and the caret
|
|
151
|
+
function caret(el) {
|
|
152
|
+
try { if (el && /^(INPUT|TEXTAREA)$/.test(el.tagName) && el.selectionStart != null) return { start: el.selectionStart, end: el.selectionEnd, dir: el.selectionDirection } } catch {}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
function onFocusIn(e) { const el = e.target; if (!el || el.nodeType !== 1 || restoring) return; focus = { el, key: scrollKey(el), route: route(), ...(caret(el) || {}) } }
|
|
156
|
+
function onFocusOut(e) {
|
|
157
|
+
const el = e.target
|
|
158
|
+
// a real blur (you clicked away) leaves the element in the page; one a redraw caused takes it out
|
|
159
|
+
win.setTimeout(() => { if (focus && focus.el === el && el.isConnected && doc.activeElement !== el) focus = null }, 0)
|
|
160
|
+
}
|
|
161
|
+
function onSel() { const a = doc.activeElement; if (focus && a === focus.el) Object.assign(focus, caret(a) || {}) }
|
|
162
|
+
|
|
163
|
+
// -- right after any change to the page
|
|
164
|
+
function restoreAll() {
|
|
165
|
+
const r = route() + '|'
|
|
166
|
+
for (const [k, rec] of recs) {
|
|
167
|
+
if (!k.startsWith(r)) continue
|
|
168
|
+
let el = rec.el.deref()
|
|
169
|
+
if (!el || !el.isConnected) el = elFor(k.slice(r.length))
|
|
170
|
+
if (!el) continue
|
|
171
|
+
if (el !== rec.el.deref()) rec.el = W(el)
|
|
172
|
+
const cur = { top: el.scrollTop, left: el.scrollLeft }
|
|
173
|
+
const want = restoreFor(rec, cur, { wroteAt: wrote.get(el) || 0 })
|
|
174
|
+
if (!want) continue
|
|
175
|
+
set(el, want.top, want.left)
|
|
176
|
+
stats.restored++
|
|
177
|
+
if (want.top != null && el.scrollTop < want.top - 1) { pending.set(el, { top: want.top, left: want.left, until: now() + 2000, set: el.scrollTop }); tick() }
|
|
178
|
+
}
|
|
179
|
+
for (const [k, open] of details) {
|
|
180
|
+
if (!k.startsWith(r)) continue
|
|
181
|
+
const d = findByKey(doc, k.slice(r.length))
|
|
182
|
+
if (d && d.tagName === 'DETAILS' && d.open !== open && !d.__skSeen) { d.__skSeen = true; restoring++; try { d.open = open } finally { restoring-- }; stats.reopened++ }
|
|
183
|
+
if (d) d.__skSeen = true
|
|
184
|
+
}
|
|
185
|
+
const a = doc.activeElement
|
|
186
|
+
if (focus && focus.route !== route()) focus = null // another page: what had focus there stays there
|
|
187
|
+
if (focus && !focus.el.isConnected && (!a || a === doc.body)) {
|
|
188
|
+
const el = findByKey(doc, focus.key)
|
|
189
|
+
if (el && typeof el.focus === 'function' && !el.disabled) {
|
|
190
|
+
restoring++
|
|
191
|
+
try {
|
|
192
|
+
el.focus({ preventScroll: true })
|
|
193
|
+
if (focus.start != null && caret(el) && el.value.length >= focus.end) el.setSelectionRange(focus.start, focus.end, focus.dir || 'none')
|
|
194
|
+
} catch {} finally { restoring-- }
|
|
195
|
+
if (doc.activeElement === el) { focus = { ...focus, el }; stats.refocused++ }
|
|
196
|
+
} else focus = null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// a box the content wasn't tall enough for yet: try again for a moment as it grows
|
|
200
|
+
let ticking = false
|
|
201
|
+
function tick() {
|
|
202
|
+
if (ticking) return
|
|
203
|
+
ticking = true
|
|
204
|
+
const step = () => {
|
|
205
|
+
const t = now()
|
|
206
|
+
for (const [el, p] of pending) {
|
|
207
|
+
if (t > p.until || !el.isConnected || user() || el.scrollTop !== p.set) { pending.delete(el); continue }
|
|
208
|
+
set(el, p.top, p.left)
|
|
209
|
+
p.set = el.scrollTop
|
|
210
|
+
if (Math.abs(el.scrollTop - p.top) <= 1) { pending.delete(el); record(el) }
|
|
211
|
+
}
|
|
212
|
+
if (pending.size) win.requestAnimationFrame(step); else ticking = false
|
|
213
|
+
}
|
|
214
|
+
win.requestAnimationFrame(step)
|
|
215
|
+
}
|
|
216
|
+
function onMutations(list) {
|
|
217
|
+
for (const m of list) if (m.addedNodes.length || m.removedNodes.length) { restoreAll(); for (const [el] of pending) if (el.isConnected && el.scrollTop !== pending.get(el).set) pending.delete(el); return }
|
|
218
|
+
}
|
|
219
|
+
function onToggle(e) {
|
|
220
|
+
const d = e.target
|
|
221
|
+
if (!d || d.tagName !== 'DETAILS' || restoring) return
|
|
222
|
+
d.__skSeen = true
|
|
223
|
+
if (now() - lastInput < 600) details.set(route() + '|' + scrollKey(d), d.open)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function start() {
|
|
227
|
+
patchProto()
|
|
228
|
+
const input = () => { lastInput = now() }
|
|
229
|
+
for (const ev of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown', 'mousedown']) win.addEventListener(ev, input, { capture: true, passive: true })
|
|
230
|
+
doc.addEventListener('scroll', onScroll, { capture: true, passive: true })
|
|
231
|
+
doc.addEventListener('focusin', onFocusIn, true)
|
|
232
|
+
doc.addEventListener('focusout', onFocusOut, true)
|
|
233
|
+
doc.addEventListener('selectionchange', onSel)
|
|
234
|
+
doc.addEventListener('keyup', onSel, true)
|
|
235
|
+
doc.addEventListener('input', onSel, true)
|
|
236
|
+
doc.addEventListener('toggle', onToggle, true)
|
|
237
|
+
new win.MutationObserver(onMutations).observe(doc.body, { childList: true, subtree: true })
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
start,
|
|
241
|
+
restoreNow: restoreAll,
|
|
242
|
+
// our own restore scrolls: a tip that hides on scroll stays up for these
|
|
243
|
+
quiet: () => now() < quietUntil,
|
|
244
|
+
stats,
|
|
245
|
+
_recs: recs,
|
|
246
|
+
}
|
|
247
|
+
}
|
package/ui/walk-pop.js
CHANGED
|
@@ -61,7 +61,7 @@ export function popHtml(c, { esc, icon }) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// the popover itself: one element on <body>, anchored under the button it explains
|
|
64
|
-
export function initPops({ esc, icon, contentFor, doc = document, win = window }) {
|
|
64
|
+
export function initPops({ esc, icon, contentFor, doc = document, win = window, quiet = () => false }) {
|
|
65
65
|
const pop = doc.createElement('div')
|
|
66
66
|
pop.className = 'lo-pop'; pop.id = 'loPop'; pop.setAttribute('role', 'tooltip'); pop.hidden = true
|
|
67
67
|
doc.body.appendChild(pop)
|
|
@@ -129,7 +129,7 @@ export function initPops({ esc, icon, contentFor, doc = document, win = window }
|
|
|
129
129
|
}, true)
|
|
130
130
|
doc.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) { hide(); e.stopPropagation() } }, true)
|
|
131
131
|
for (const ev of ['resize', 'hashchange']) win.addEventListener(ev, () => { if (cur) hide() }, true)
|
|
132
|
-
// a scroll that moves the button closes it (the thread scrolling beside it doesn't)
|
|
133
|
-
win.addEventListener('scroll', (e) => { const t = e.target; if (cur && t && (t === doc || t === win || (t.contains && t.contains(cur)))) hide() }, true)
|
|
132
|
+
// a scroll that moves the button closes it (the thread scrolling beside it doesn't; 0.9.18 nor a redraw putting a box back, quiet())
|
|
133
|
+
win.addEventListener('scroll', (e) => { const t = e.target; if (cur && !quiet() && t && (t === doc || t === win || (t.contains && t.contains(cur)))) hide() }, true)
|
|
134
134
|
return { hide, refresh, el: pop }
|
|
135
135
|
}
|