@leftoff/cli 0.9.17-beta.5 → 0.9.17-beta.7
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 +7 -0
- package/bin/leftoff.js +445 -440
- 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 +182 -15
- package/ui/app.js +334 -29
- package/ui/icons.js +3 -0
- package/ui/index.html +16 -12
- package/ui/live-view.js +3 -0
- package/ui/navrail.js +99 -0
- package/ui/needs.js +66 -20
- package/ui/prs.js +53 -8
- package/ui/scroll-keep.js +247 -0
- package/ui/walk-pop.js +3 -3
|
@@ -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
|
}
|