Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | 2x 2x 24x 24x 24x 22x 22x 22x 22x 22x 22x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 17x 17x 17x 17x 24x 24x 2x 2x 13x 13x 13x 13x 11x 11x 11x 11x 11x 13x 13x 13x 13x 13x 2x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 6x 6x 6x 6x 11x 4x 4x 11x 11x 11x 3x 3x 3x 3x 3x 11x 1x 1x 1x 1x 1x 1x 11x 4x 4x 4x 4x 4x 11x 11x 11x | /**
* A small, self-dismissing note for work that finishes *after* the thing the
* reader was waiting for.
*
* There is exactly one job like that in the application, and this exists for
* it: the `.mcool` written from a `.pairs` import. The map is already on
* screen and already usable — the export is a second job over arrays that are
* in memory, and blocking the view behind it would be making the reader wait
* for a file rather than for a picture.
*
* So it is not a modal, not a panel, and not a line in the status bar. It is a
* note in the corner that says what is happening, moves while it happens, and
* takes itself away when it is done. A failure stays until it is dismissed,
* because a download that silently did not happen is worse than one that says
* so.
*
* The same corner serves a second, simpler case: something happened away from
* whatever the reader is looking at, and they need to be told once — the store
* drawer's *copy link*, the console asked to copy a log that has nothing in it.
* {@link messageToast} is that one line: same box, same ×, no bar, gone in four
* seconds.
*
* **A surface that has a line of its own does not use this one.** The share
* dialog used to raise a corner note to say it had copied the link, which put
* the answer to a gesture made in a dialog nine hundred pixels away from it,
* in different words from the ones its own copy button produced; it writes into
* its own note now (`ui/ShareDialog.ts`). The corner is for what has no such
* place, which is why both callers left are panels raising a line about
* something that is not on screen.
*/
import { el } from './dom'
export interface ProgressToast {
update(done: number, total: number, note?: string): void
/** Show a final line, then fade out. */
finish(message: string): void
/** Show a failure and leave it until the reader closes it. */
fail(message: string): void
/** Take it away now. */
dismiss(): void
}
/** How long a finished note stays before it goes. */
const LINGER_MS = 4_000
/**
* The corner the notes are drawn in: one column, made on demand, shared by both
* kinds of note.
*
* `.toast` anchors *itself* to the bottom-right, which is right for one note and
* wrong for two — the second is drawn on exactly the same pixels as the first
* and hides it. That is not hypothetical. A truncated `.pairs` import raises the
* "Incomplete file" warning, which is the one note in this application promised
* to stay until it is dismissed, and then starts the `.mcool` write on the very
* next statement (`app/Session.ts`); the progress note landed over the warning
* and left the reader believing a chromosome genuinely has no contacts.
*
* So the corner is a column rather than a point. `column-reverse` keeps the
* newest note nearest the corner and pushes the older ones up, so each keeps its
* own text and its own ×. Both constructors reach it through
* {@link toastShell}: one rule about where a note goes rather than one per kind,
* which is how the two came to disagree in the first place.
*/
function stack(): HTMLElement {
const found = document.querySelector<HTMLElement>('.toast-stack')
if (found !== null) return found
const node = el('div', {
class: 'toast-stack',
// The anchoring `.toast` carries in the stylesheet, one level up, so the
// notes inside are otherwise exactly the boxes it describes. The column is
// as wide as its widest note, so `pointer-events` is off on it and on again
// for each note: a strip of nothing beside a short note must not swallow a
// click meant for the map behind it.
})
document.body.appendChild(node)
return node
}
/**
* The box itself: the corner panel, the line of text, and the ×.
*
* Both notes are the same object to a reader — same corner, same border, same
* dismiss — and the only difference is what sits under the first line. Built
* once here rather than twice, because the halves that must not drift are the
* ones nobody looks at: `role="status"` with `aria-live="polite"`, which is the
* whole of what makes a corner note reach somebody using a screen reader, and
* the class names the stylesheet positions by.
*
* The extra rows are appended *after* the label and inside `.toast-text`, which
* is the order `progressToast` has always built — label, bar, note — so its DOM
* comes out of here byte for byte what it was.
*/
function toastShell(
message: string,
...rows: HTMLElement[]
): { root: HTMLElement; close: HTMLButtonElement; remove: () => void } {
const label = el('p', { class: 'toast-label', text: message })
const close = el('button', { class: 'toast-close', text: '×', title: 'Dismiss' })
const root = el(
'div',
{
class: 'toast',
// Placed by the column rather than by itself; see {@link stack} and the
// `.toast-stack` rule in the stylesheet, which is where the geometry of
// both now lives.
attrs: { role: 'status', 'aria-live': 'polite' },
},
el('div', { class: 'toast-text' }, label, ...rows),
close,
)
stack().appendChild(root)
/**
* Take this note away — and the column with it, when it was the last one.
*
* A column left standing would be inherited by whatever opens next: `Modal`
* marks every child of the body inert while a dialog is up, so an empty one
* kept from an hour ago would take the × off a note raised *during* the
* dialog. Nothing outlives the notes it holds.
*/
const remove = (): void => {
const column = root.parentElement
root.remove()
if (column !== null && column.childElementCount === 0) column.remove()
}
return { root, close, remove }
}
/**
* The one standing message note, so a second one replaces it rather than being
* added under it.
*
* Not about overlap any more — {@link stack} is what keeps two notes off each
* other, and it keeps *every* pair off each other rather than only this one.
* What is left here is a rule about repetition: these notes answer a press, and
* a press repeated inside the four seconds the first note stays — *copy link*
* twice on a store, *copy* twice on an empty log — would otherwise stack a
* second row saying nothing the first did not. A progress note is never a
* repeat of the note beside it, which is why it does not take part.
*/
let standing: (() => void) | null = null
/**
* A single self-dismissing line: something happened, and it is done.
*
* Distinct from {@link progressToast} in the one way that matters — there is no
* bar and no running commentary, because there is nothing to run. A progress
* bar on a note about a finished one-shot action is a promise of a second event
* that never arrives, and the × beside it would be offering to cancel work that
* is already over. So it opens in the settled state, and goes on its own.
*
* It returns nothing on purpose: a caller with a handle would be a caller
* tempted to update a note that has no second state to move to.
*/
export function messageToast(message: string): void {
standing?.()
const { root, close, remove } = toastShell(message)
let timer: ReturnType<typeof setTimeout> | null = null
const dismiss = (): void => {
if (timer !== null) clearTimeout(timer)
timer = null
if (standing === dismiss) standing = null
remove()
}
close.addEventListener('click', dismiss)
// Settled from the first frame: this note is never anything but finished, and
// the class is what says so in the same grey the finished progress note ends
// in — a reader who has seen one recognises the other.
root.classList.add('toast-done')
timer = setTimeout(dismiss, LINGER_MS)
standing = dismiss
}
export function progressToast(
message: string,
/**
* Called when the reader closes the note before it finishes.
*
* Not optional in spirit, even though it is in the type: a note with a bar on
* it and a × that only removes the bar is a lie about what the × does. The
* one caller cancels the job.
*/
onDismiss?: () => void,
): ProgressToast {
const fill = el('div', { class: 'toast-fill' })
const bar = el('div', { class: 'toast-bar' }, fill)
// The app's one spelling of the state, as the status bar and the two
// progress views write it (§26.7).
const note = el('p', { class: 'toast-note', text: 'starting…' })
const { root, close, remove } = toastShell(message, bar, note)
let timer: ReturnType<typeof setTimeout> | null = null
let settled = false
const dismiss = (): void => {
if (timer !== null) clearTimeout(timer)
timer = null
remove()
}
close.addEventListener('click', () => {
// Only while the work is still running: closing a *finished* note is
// tidying up, and calling off a job that has already handed back its
// result would be a cancel with nothing to cancel.
if (!settled) onDismiss?.()
dismiss()
})
return {
update(done, total, text) {
const fraction = total > 0 ? Math.max(0, Math.min(1, done / total)) : 0
fill.style.width = `${(fraction * 100).toFixed(1)}%`
note.textContent =
text === undefined ? `${Math.round(fraction * 100)}%` : `${text} — ${Math.round(fraction * 100)}%`
},
finish(text) {
settled = true
fill.style.width = '100%'
note.textContent = text
root.classList.add('toast-done')
timer = setTimeout(dismiss, LINGER_MS)
},
fail(text) {
settled = true
bar.remove()
note.textContent = text
root.classList.add('toast-failed')
},
dismiss,
}
}
|