rvlib-shipkit 2.2.0 → 2.3.0
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 +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// a repo's review page as state (wiki build/shipkit/review.md, The review page): the cards on disk, the open one, the
|
|
2
|
+
// filters, and his draft per card (rectangles, reactions and comments not sent yet), kept in the browser until it is
|
|
3
|
+
// sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
|
|
4
|
+
import { makeAutoObservable, reaction, runInAction } from 'mobx'
|
|
5
|
+
import {
|
|
6
|
+
isGallery,
|
|
7
|
+
laneOf,
|
|
8
|
+
moveOf,
|
|
9
|
+
reworkOf,
|
|
10
|
+
stateOf,
|
|
11
|
+
unreadBy,
|
|
12
|
+
verdictOf,
|
|
13
|
+
type MediaView,
|
|
14
|
+
type Message,
|
|
15
|
+
type Move,
|
|
16
|
+
type Rect,
|
|
17
|
+
type ReviewItem,
|
|
18
|
+
type ReviewReaction,
|
|
19
|
+
type ReviewStatus,
|
|
20
|
+
type ShownStatus,
|
|
21
|
+
type Side,
|
|
22
|
+
type Spec,
|
|
23
|
+
} from '../../../review/model.ts'
|
|
24
|
+
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
|
|
25
|
+
import { reach } from '../reach.ts'
|
|
26
|
+
import type { RepoSt } from '../repo-st.ts'
|
|
27
|
+
import type { Role } from '../../../review/role.ts'
|
|
28
|
+
import { ClipSt } from './clip-st.ts'
|
|
29
|
+
|
|
30
|
+
interface Draft {
|
|
31
|
+
rects: Rect[]
|
|
32
|
+
comment: string
|
|
33
|
+
reactions?: ReviewReaction[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** a live `sk review watch`: its process, the session or lane it keeps, when it started */
|
|
37
|
+
export interface Watcher {
|
|
38
|
+
pid: number
|
|
39
|
+
session: string | null
|
|
40
|
+
lane: string | null
|
|
41
|
+
started: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Listing {
|
|
45
|
+
items: ReviewItem[]
|
|
46
|
+
broken: { id: string; error: string }[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** the zoom of a view: fit the column (and the window's height), or a share of the image's own pixels */
|
|
50
|
+
export const ZOOMS = ['fit', '1', '1.5', '2'] as const
|
|
51
|
+
export type Zoom = (typeof ZOOMS)[number]
|
|
52
|
+
|
|
53
|
+
/** how a pair of images shows: side by side, the curtain, one over the other, the after alone */
|
|
54
|
+
export type CompareMode = 'side' | 'swipe' | 'onion' | 'mod'
|
|
55
|
+
export const COMPARE_MODES: readonly CompareMode[] = ['side', 'swipe', 'onion', 'mod']
|
|
56
|
+
/** the side a rectangle lands on: the after alone draws on the after */
|
|
57
|
+
export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mod' ? 'after' : drawn)
|
|
58
|
+
|
|
59
|
+
/** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
|
|
60
|
+
/** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
|
|
61
|
+
export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
|
|
62
|
+
|
|
63
|
+
export const PHONE_PX = 768
|
|
64
|
+
/** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
|
|
65
|
+
* the card over the answer in one column beside the list */
|
|
66
|
+
export const STACK_PX = 900
|
|
67
|
+
/** where the card sits against its answer: on top of it, left of it, or by the page's width */
|
|
68
|
+
export const CARD_PLACES = ['top', 'left', 'auto'] as const
|
|
69
|
+
export type CardPlace = (typeof CARD_PLACES)[number]
|
|
70
|
+
export type PhonePane = 'list' | 'card' | 'answer'
|
|
71
|
+
export type PairSide = 'before' | 'after' | 'swipe'
|
|
72
|
+
|
|
73
|
+
const POLL_MS = 2000
|
|
74
|
+
|
|
75
|
+
const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
|
|
76
|
+
|
|
77
|
+
/** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
|
|
78
|
+
const store = {
|
|
79
|
+
get(k: string): string | null {
|
|
80
|
+
try {
|
|
81
|
+
return localStorage.getItem(k)
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
put(k: string, v: string | null): void {
|
|
87
|
+
try {
|
|
88
|
+
if (v === null) localStorage.removeItem(k)
|
|
89
|
+
else localStorage.setItem(k, v)
|
|
90
|
+
} catch {
|
|
91
|
+
// as above
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const draftOf = (s: string | null): Draft | null => {
|
|
97
|
+
try {
|
|
98
|
+
const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
|
|
99
|
+
if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
|
|
100
|
+
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}) }
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** a loader for a polled list: it sends the tag of its last answer, and on « not modified » gives that answer again */
|
|
107
|
+
function unchangedAware<T>(url: () => string): () => Promise<T> {
|
|
108
|
+
let last: { tag: string; value: T } | null = null
|
|
109
|
+
return async () => {
|
|
110
|
+
const res = await reach(url(), { cache: 'no-store', headers: last ? { 'If-None-Match': last.tag } : {} })
|
|
111
|
+
if (res.status === 304 && last) return last.value
|
|
112
|
+
if (!res.ok) throw new Error(`${url()}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim())
|
|
113
|
+
const value = (await res.json()) as T
|
|
114
|
+
const tag = res.headers.get('ETag')
|
|
115
|
+
last = tag ? { tag, value } : null
|
|
116
|
+
return value
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
121
|
+
const res = await reach(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
122
|
+
const text = await res.text()
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
let msg = text
|
|
125
|
+
try {
|
|
126
|
+
msg = (JSON.parse(text) as { error?: string }).error ?? text
|
|
127
|
+
} catch {
|
|
128
|
+
// a plain text answer says it already
|
|
129
|
+
}
|
|
130
|
+
throw new Error(msg || `HTTP ${res.status}`)
|
|
131
|
+
}
|
|
132
|
+
return JSON.parse(text) as T
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
|
|
136
|
+
export class CompareSt {
|
|
137
|
+
mode: CompareMode = 'side'
|
|
138
|
+
split = 0.5
|
|
139
|
+
opacity = 0.5
|
|
140
|
+
constructor(private readonly key: string) {
|
|
141
|
+
const m = store.get(key)
|
|
142
|
+
if (m && (COMPARE_MODES as readonly string[]).includes(m)) this.mode = m as CompareMode
|
|
143
|
+
makeAutoObservable<this, 'key'>(this, { key: false })
|
|
144
|
+
}
|
|
145
|
+
setMode(m: CompareMode): void {
|
|
146
|
+
this.mode = m
|
|
147
|
+
store.put(this.key, m)
|
|
148
|
+
}
|
|
149
|
+
setSplit(v: number): void {
|
|
150
|
+
this.split = Math.max(0, Math.min(1, v))
|
|
151
|
+
}
|
|
152
|
+
setOpacity(v: number): void {
|
|
153
|
+
this.opacity = Math.max(0, Math.min(1, v))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** the phone's state: which pane shows, « circle » drawing on, the side of a pair */
|
|
158
|
+
export class PhoneSt {
|
|
159
|
+
on = false
|
|
160
|
+
pane: PhonePane = 'list'
|
|
161
|
+
draw = false
|
|
162
|
+
pair: PairSide = 'after'
|
|
163
|
+
constructor() {
|
|
164
|
+
makeAutoObservable(this)
|
|
165
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
166
|
+
const mq = window.matchMedia(`(max-width: ${PHONE_PX - 1}px)`)
|
|
167
|
+
this.on = mq.matches
|
|
168
|
+
mq.addEventListener('change', (e) => runInAction(() => (this.on = e.matches)))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
show(p: PhonePane): void {
|
|
172
|
+
this.pane = p
|
|
173
|
+
}
|
|
174
|
+
toggleDraw(): void {
|
|
175
|
+
this.draw = !this.draw
|
|
176
|
+
}
|
|
177
|
+
setPair(s: PairSide): void {
|
|
178
|
+
this.pair = s
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class ReviewSt {
|
|
183
|
+
list: Listing | null = null
|
|
184
|
+
listError: string | null = null
|
|
185
|
+
/** the repo's review unit in words (its kinds, topics, dispatcher and its holder), read when the page starts */
|
|
186
|
+
spec: RepoSpec | null = null
|
|
187
|
+
/** the dispatcher's board in place of the cards */
|
|
188
|
+
board = false
|
|
189
|
+
watchers: Watcher[] = []
|
|
190
|
+
/** each clip view's own clock on the open card, by view index, made on first use */
|
|
191
|
+
private readonly clips = new Map<number, ClipSt>()
|
|
192
|
+
private clipsCard = ''
|
|
193
|
+
selectedId: string | null = null
|
|
194
|
+
query = ''
|
|
195
|
+
subject: string | null = null
|
|
196
|
+
whose: Move | null = null
|
|
197
|
+
status: ShownStatus | null = null
|
|
198
|
+
drafts: Record<string, Draft> = {}
|
|
199
|
+
/** the rectangle of the open card picked on the image, its index */
|
|
200
|
+
rect: number | null = null
|
|
201
|
+
/** the subject rings over the image (M) */
|
|
202
|
+
marksOn = true
|
|
203
|
+
/** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
|
|
204
|
+
view = 0
|
|
205
|
+
zoom: Zoom = 'fit'
|
|
206
|
+
viewZoom: Record<number, Zoom> = {}
|
|
207
|
+
/** the gallery tile shown large across the column, `<view>/<tile>` */
|
|
208
|
+
bigTile: string | null = null
|
|
209
|
+
replies: Record<string, string> = {}
|
|
210
|
+
private reading = new Set<string>()
|
|
211
|
+
/** « hide handled »: only the active threads in the list */
|
|
212
|
+
hideHandled = true
|
|
213
|
+
/** « by lane »: the list grouped under one header per lane */
|
|
214
|
+
byLane = false
|
|
215
|
+
/** the threads whose older versions are unfolded, by head id */
|
|
216
|
+
unfolded: Record<string, boolean> = {}
|
|
217
|
+
busy = false
|
|
218
|
+
error: string | null = null
|
|
219
|
+
/** the card against its answer, his pick, kept with the prefs */
|
|
220
|
+
place: CardPlace = 'auto'
|
|
221
|
+
/** the page is narrower than STACK_PX */
|
|
222
|
+
narrow = false
|
|
223
|
+
readonly compare: CompareSt
|
|
224
|
+
readonly phone = new PhoneSt()
|
|
225
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
226
|
+
private readonly loadList: () => Promise<Listing>
|
|
227
|
+
private readonly loadWatchers: () => Promise<Watcher[]>
|
|
228
|
+
private readonly prefix: string
|
|
229
|
+
|
|
230
|
+
/** the page measured its width */
|
|
231
|
+
fit(px: number): void {
|
|
232
|
+
const on = px > 0 && px < STACK_PX
|
|
233
|
+
if (this.narrow !== on) this.narrow = on
|
|
234
|
+
}
|
|
235
|
+
/** the card over its answer in one column: picked, or `auto` on a narrow page */
|
|
236
|
+
get stacked(): boolean {
|
|
237
|
+
return this.place === 'top' || (this.place === 'auto' && this.narrow)
|
|
238
|
+
}
|
|
239
|
+
setPlace(p: CardPlace): void {
|
|
240
|
+
this.place = p
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
constructor(private readonly st: RepoSt) {
|
|
244
|
+
this.prefix = `shipkit.review.${st.key}.`
|
|
245
|
+
this.loadList = unchangedAware(() => st.url('/api/review'))
|
|
246
|
+
this.loadWatchers = unchangedAware(() => st.url('/api/review/watchers'))
|
|
247
|
+
this.compare = new CompareSt(`${this.prefix}compare`)
|
|
248
|
+
try {
|
|
249
|
+
const p = JSON.parse(store.get(`${this.prefix}prefs`) ?? '{}') as { hideHandled?: boolean; byLane?: boolean; zoom?: string; marks?: boolean; place?: string }
|
|
250
|
+
if (p.hideHandled === false) this.hideHandled = false
|
|
251
|
+
if (p.byLane === true) this.byLane = true
|
|
252
|
+
if (p.marks === false) this.marksOn = false
|
|
253
|
+
if ((ZOOMS as readonly string[]).includes(p.zoom ?? '')) this.zoom = p.zoom as Zoom
|
|
254
|
+
if ((CARD_PLACES as readonly string[]).includes(p.place ?? '')) this.place = p.place as CardPlace
|
|
255
|
+
} catch {
|
|
256
|
+
// a broken pref is the default
|
|
257
|
+
}
|
|
258
|
+
makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
|
|
259
|
+
st: false,
|
|
260
|
+
clips: false,
|
|
261
|
+
clipsCard: false,
|
|
262
|
+
reading: false,
|
|
263
|
+
timer: false,
|
|
264
|
+
loadList: false,
|
|
265
|
+
loadWatchers: false,
|
|
266
|
+
prefix: false,
|
|
267
|
+
compare: false,
|
|
268
|
+
phone: false,
|
|
269
|
+
})
|
|
270
|
+
// opening a card reads the agent's messages on its whole thread
|
|
271
|
+
reaction(
|
|
272
|
+
() => (this.openThread ? membersOf(this.openThread) : []).filter((x) => unreadBy(x, 'owner').length > 0).map((x) => x.item.id),
|
|
273
|
+
(ids) => {
|
|
274
|
+
for (const id of ids) void this.markRead(id)
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
reaction(
|
|
278
|
+
() => JSON.stringify({ hideHandled: this.hideHandled, byLane: this.byLane, zoom: this.zoom, marks: this.marksOn, place: this.place }),
|
|
279
|
+
(s) => store.put(`${this.prefix}prefs`, s),
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** the list and the watchers, read now and every 2 s while the page shows; the unit's words once a start */
|
|
284
|
+
start(): void {
|
|
285
|
+
void this.refresh()
|
|
286
|
+
void fetch(this.st.url('/api/review/spec'))
|
|
287
|
+
.then((r) => (r.ok ? (r.json() as Promise<RepoSpec>) : null))
|
|
288
|
+
.catch(() => null)
|
|
289
|
+
.then((spec) => runInAction(() => (this.spec = spec)))
|
|
290
|
+
if (this.timer === null) this.timer = setInterval(() => void this.refresh(), POLL_MS)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
stop(): void {
|
|
294
|
+
if (this.timer !== null) clearInterval(this.timer)
|
|
295
|
+
this.timer = null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async refresh(): Promise<void> {
|
|
299
|
+
try {
|
|
300
|
+
const [list, watchers] = await Promise.all([this.loadList(), this.loadWatchers().catch(() => this.watchers)])
|
|
301
|
+
runInAction(() => {
|
|
302
|
+
if (list !== this.list) this.list = list
|
|
303
|
+
if (watchers !== this.watchers) this.watchers = watchers
|
|
304
|
+
this.listError = null
|
|
305
|
+
})
|
|
306
|
+
} catch (e) {
|
|
307
|
+
runInAction(() => (this.listError = (e as Error).message))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** the card's folder, or one of its files, shown in the Finder of the machine the hub runs on */
|
|
312
|
+
async reveal(id: string, file?: string): Promise<void> {
|
|
313
|
+
try {
|
|
314
|
+
await postJson(this.st.url(`/api/review/${id}/reveal`), file ? { file } : {})
|
|
315
|
+
} catch (e) {
|
|
316
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
setBoard(on: boolean): void {
|
|
321
|
+
this.board = on
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fileUrl(id: string, name: string): string {
|
|
325
|
+
return this.st.url(`/api/review/${id}/${name}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
thumbUrl(x: ReviewItem, name: string): string {
|
|
329
|
+
return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
threadImageUrl(id: string, name: string): string {
|
|
333
|
+
return this.st.url(`/api/review/${id}/thread/${name}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
get items(): ReviewItem[] {
|
|
337
|
+
return this.list?.items ?? []
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
get broken(): Listing['broken'] {
|
|
341
|
+
return this.list?.broken ?? []
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
statusOf(x: ReviewItem): ShownStatus {
|
|
345
|
+
return statusOf(x, this.items)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
get followed(): Set<string> {
|
|
349
|
+
return followedIds(this.items)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
reworkOf(x: ReviewItem): ReturnType<typeof reworkOf> {
|
|
353
|
+
return reworkOf(x, this.followed.has(x.item.id), new Date())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** the live watches that read his answer on any of these cards */
|
|
357
|
+
listenersOf(cards: readonly ReviewItem[]): Watcher[] {
|
|
358
|
+
return this.watchers.filter((w) =>
|
|
359
|
+
cards.some((x) => (w.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === w.session)) && (w.lane === null || laneOf(x) === w.lane)),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
get threads(): Thread[] {
|
|
364
|
+
return orderedThreads(this.items)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him */
|
|
368
|
+
get pendingCount(): number {
|
|
369
|
+
return this.threads.filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && this.moveOf(t.head) === 'owner')).length
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
get activeCount(): number {
|
|
373
|
+
return this.threads.filter((t) => isActive(t)).length
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** the subject kinds this store holds, for the filter */
|
|
377
|
+
get subjects(): string[] {
|
|
378
|
+
return [...new Set(this.items.map((x) => x.item.subject.kind))].sort()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** the list as the switch, the filters and the search show it: one thread per head */
|
|
382
|
+
get visible(): Thread[] {
|
|
383
|
+
const q = this.query.trim().toLowerCase()
|
|
384
|
+
return this.threads.filter(
|
|
385
|
+
(t) =>
|
|
386
|
+
(!this.hideHandled || isActive(t)) &&
|
|
387
|
+
(this.subject === null || t.head.item.subject.kind === this.subject) &&
|
|
388
|
+
(this.whose === null || this.moveOf(t.head) === this.whose) &&
|
|
389
|
+
(this.status === null || this.statusOf(t.head) === this.status) &&
|
|
390
|
+
(q === '' || membersOf(t).some((x) => `${x.item.title} ${x.item.what ?? ''} ${x.item.id}`.toLowerCase().includes(q))),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** the visible threads under one header per lane; null: the cards with no lane */
|
|
395
|
+
get laneGroups(): { lane: string | null; threads: Thread[] }[] {
|
|
396
|
+
const groups = new Map<string | null, Thread[]>()
|
|
397
|
+
for (const t of this.visible) {
|
|
398
|
+
const lane = laneOf(t.head) ?? null
|
|
399
|
+
groups.set(lane, [...(groups.get(lane) ?? []), t])
|
|
400
|
+
}
|
|
401
|
+
const all = [...groups].map(([lane, threads]) => ({ lane, threads }))
|
|
402
|
+
return [...all.filter((g) => g.lane !== null), ...all.filter((g) => g.lane === null)]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
get open(): ReviewItem | null {
|
|
406
|
+
return this.items.find((x) => x.item.id === this.selectedId) ?? this.visible[0]?.head ?? this.threads[0]?.head ?? null
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
get openThread(): Thread | null {
|
|
410
|
+
const id = this.open?.item.id
|
|
411
|
+
return id === undefined ? null : threadOfItem(id, this.threads)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** the reports and notes nested in the open card */
|
|
415
|
+
get answered(): ReviewItem[] {
|
|
416
|
+
const t = this.openThread
|
|
417
|
+
const id = this.open?.item.id
|
|
418
|
+
return t && t.versions.some((x) => x.item.id === id) ? t.answers : []
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
get answeredBy(): ReviewItem | null {
|
|
422
|
+
const x = this.open
|
|
423
|
+
return x && x.item.origin.by !== 'agent' ? answererOf(x.item.id, this.items) : null
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** the older versions of the open card, the newest of them first */
|
|
427
|
+
get history(): ReviewItem[] {
|
|
428
|
+
const x = this.open
|
|
429
|
+
return x ? chainOf(x, this.items).slice(1) : []
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
get newer(): ReviewItem[] {
|
|
433
|
+
const x = this.open
|
|
434
|
+
return x ? childrenOf(x.item.id, this.items) : []
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
showsVersions(t: Thread): boolean {
|
|
438
|
+
return this.unfolded[t.head.item.id] === true || t.versions.slice(1).some((x) => x.item.id === this.selectedId)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fold(headId: string): void {
|
|
442
|
+
this.unfolded = { ...this.unfolded, [headId]: !this.unfolded[headId] }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setHideHandled(on: boolean): void {
|
|
446
|
+
this.hideHandled = on
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
setByLane(on: boolean): void {
|
|
450
|
+
this.byLane = on
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
setQuery(q: string): void {
|
|
454
|
+
this.query = q
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
setFilter(f: { subject?: string | null; whose?: Move | null; status?: ShownStatus | null }): void {
|
|
458
|
+
if (f.subject !== undefined) this.subject = f.subject
|
|
459
|
+
if (f.whose !== undefined) this.whose = f.whose
|
|
460
|
+
if (f.status !== undefined) this.status = f.status
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
moveOf(x: ReviewItem): Move {
|
|
464
|
+
return moveOf(stateOf(x, this.followed.has(x.item.id)).state)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** what the form edits: the draft, else the sent feedback, else nothing yet */
|
|
468
|
+
get draft(): Draft {
|
|
469
|
+
const x = this.open
|
|
470
|
+
if (!x) return { rects: [], comment: '' }
|
|
471
|
+
const v = verdictOf(x)
|
|
472
|
+
const sent = v?.reactions
|
|
473
|
+
return this.drafts[x.item.id] ?? { rects: v?.rects ?? [], comment: v?.comment ?? '', ...(sent && sent.length > 0 ? { reactions: sent } : {}) }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
get dirty(): boolean {
|
|
477
|
+
const x = this.open
|
|
478
|
+
return x !== null && this.drafts[x.item.id] !== undefined
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private edit(f: (d: Draft) => Draft): void {
|
|
482
|
+
const x = this.open
|
|
483
|
+
if (!x) return
|
|
484
|
+
const d = this.draft
|
|
485
|
+
const { reactions, ...rest } = f({ rects: [...d.rects], comment: d.comment, reactions: [...(d.reactions ?? [])] })
|
|
486
|
+
const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
|
|
487
|
+
this.drafts = { ...this.drafts, [x.item.id]: next }
|
|
488
|
+
store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** opens a card from the page: its address follows */
|
|
492
|
+
pick(id: string): void {
|
|
493
|
+
this.st.showReview(id)
|
|
494
|
+
if (this.phone.on) this.phone.show('card')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** the card the address names; its draft is read again from storage, the truth over every window */
|
|
498
|
+
select(id: string | null): void {
|
|
499
|
+
if (id !== this.selectedId) {
|
|
500
|
+
this.view = 0
|
|
501
|
+
this.viewZoom = {}
|
|
502
|
+
this.bigTile = null
|
|
503
|
+
}
|
|
504
|
+
this.selectedId = id
|
|
505
|
+
if (id !== null) {
|
|
506
|
+
const stored = draftOf(store.get(`${this.prefix}draft.${id}`))
|
|
507
|
+
if (stored) this.drafts = { ...this.drafts, [id]: stored }
|
|
508
|
+
else if (this.drafts[id]) {
|
|
509
|
+
const { [id]: _, ...rest } = this.drafts
|
|
510
|
+
this.drafts = rest
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
this.rect = null
|
|
514
|
+
this.error = null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** ↑↓ on the page: the next thread head the list shows */
|
|
518
|
+
move(delta: 1 | -1): void {
|
|
519
|
+
const list = this.visible
|
|
520
|
+
const at = list.findIndex((t) => t.head.item.id === this.openThread?.head.item.id)
|
|
521
|
+
const next = list[Math.max(0, Math.min(list.length - 1, at + delta))]
|
|
522
|
+
if (next) this.pick(next.head.item.id)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** N: the next pending thread head after the open one, wrapping */
|
|
526
|
+
nextPending(): void {
|
|
527
|
+
const pend = this.threads.filter((t) => waits(t.head))
|
|
528
|
+
if (pend.length === 0) return
|
|
529
|
+
const at = pend.findIndex((t) => t.head.item.id === this.open?.item.id)
|
|
530
|
+
const next = pend[(at + 1) % pend.length]
|
|
531
|
+
if (next) this.pick(next.head.item.id)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** a rectangle on a view, which becomes the active one; on a clip it keeps the time of the frame shown */
|
|
535
|
+
addRect(r: { x: number; y: number; w: number; h: number }, side: Side | undefined, on: number = this.view): void {
|
|
536
|
+
if (on !== this.view) this.setView(on)
|
|
537
|
+
const view = this.view > 0 ? { view: this.view } : {}
|
|
538
|
+
const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
|
|
539
|
+
this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
|
|
540
|
+
this.rect = this.draft.rects.length - 1
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
setView(i: number): void {
|
|
544
|
+
this.view = i
|
|
545
|
+
this.rect = null
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
get openView(): MediaView | null {
|
|
549
|
+
const v = this.open?.item.views[this.view]
|
|
550
|
+
return v && !isGallery(v) ? v : null
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
zoomOf(i: number): Zoom {
|
|
554
|
+
return this.viewZoom[i] ?? this.zoom
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
setZoom(z: Zoom): void {
|
|
558
|
+
this.zoom = z
|
|
559
|
+
this.viewZoom = {}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
setViewZoom(i: number, z: Zoom): void {
|
|
563
|
+
this.viewZoom = { ...this.viewZoom, [i]: z }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
toggleTile(view: number, tile: number): void {
|
|
567
|
+
const k = `${view}/${tile}`
|
|
568
|
+
this.bigTile = this.bigTile === k ? null : k
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
reactionOf(view: number, tile?: number): ReviewReaction | null {
|
|
572
|
+
return (this.draft.reactions ?? []).find((r) => sameSpot(r, view, tile)) ?? null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** a reaction's toggle: sets that status, or clears it when it is already the one */
|
|
576
|
+
react(view: number, tile: number | undefined, status: ReviewStatus): void {
|
|
577
|
+
const cur = this.reactionOf(view, tile)
|
|
578
|
+
this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
setReactionComment(view: number, tile: number | undefined, comment: string): void {
|
|
582
|
+
this.putReaction(view, tile, { status: this.reactionOf(view, tile)?.status, comment })
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
removeReaction(view: number, tile: number | undefined): void {
|
|
586
|
+
this.putReaction(view, tile, { status: undefined, comment: '' })
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** the reactions that go to the agent with the verdict, in the card's order */
|
|
590
|
+
get outgoingReactions(): ReviewReaction[] {
|
|
591
|
+
return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private putReaction(view: number, tile: number | undefined, r: { status: ReviewStatus | undefined; comment: string }): void {
|
|
595
|
+
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment }
|
|
596
|
+
const empty = !r.status && r.comment === ''
|
|
597
|
+
this.edit((d) => {
|
|
598
|
+
const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
|
|
599
|
+
return { ...d, reactions: empty ? rest : [...rest, next] }
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** the open view's clip identity, empty when it shows images */
|
|
604
|
+
get clipKey(): string {
|
|
605
|
+
const x = this.open
|
|
606
|
+
return x && this.openView?.clip ? `${x.item.id}#${this.view}` : ''
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** the clock of a clip view of the open card: every clip view plays and steps on its own */
|
|
610
|
+
clipOf(view: number): ClipSt {
|
|
611
|
+
const id = this.open?.item.id ?? ''
|
|
612
|
+
if (id !== this.clipsCard) {
|
|
613
|
+
this.clips.clear()
|
|
614
|
+
this.clipsCard = id
|
|
615
|
+
}
|
|
616
|
+
let c = this.clips.get(view)
|
|
617
|
+
if (!c) {
|
|
618
|
+
c = new ClipSt()
|
|
619
|
+
const v = this.open?.item.views[view]
|
|
620
|
+
const info = v && !isGallery(v) ? v.clip : undefined
|
|
621
|
+
c.load(`${id}#${view}`, info?.duration ?? 0, info?.fps ?? 20)
|
|
622
|
+
this.clips.set(view, c)
|
|
623
|
+
}
|
|
624
|
+
return c
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
get clip(): ClipSt {
|
|
628
|
+
return this.clipOf(this.view)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
get conversation(): ReturnType<typeof conversationOf> {
|
|
632
|
+
const t = this.openThread
|
|
633
|
+
return t ? conversationOf(t) : []
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** a thread holds an agent message he has not read: its card shows a dot */
|
|
637
|
+
unread(t: Thread): boolean {
|
|
638
|
+
return membersOf(t).some((x) => unreadBy(x, 'owner').length > 0)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
get reply(): string {
|
|
642
|
+
const id = this.open?.item.id
|
|
643
|
+
return id ? (this.replies[id] ?? store.get(`${this.prefix}reply.${id}`) ?? '') : ''
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
setReply(text: string): void {
|
|
647
|
+
const id = this.open?.item.id
|
|
648
|
+
if (!id) return
|
|
649
|
+
this.replies = { ...this.replies, [id]: text }
|
|
650
|
+
store.put(`${this.prefix}reply.${id}`, text || null)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** ⌘Enter or « reply »: his message goes to the open card */
|
|
654
|
+
async sendReply(): Promise<void> {
|
|
655
|
+
const x = this.open
|
|
656
|
+
const text = this.reply.trim()
|
|
657
|
+
if (!x || !text || this.busy) return
|
|
658
|
+
const id = x.item.id
|
|
659
|
+
this.busy = true
|
|
660
|
+
this.error = null
|
|
661
|
+
try {
|
|
662
|
+
await postJson(this.st.url(`/api/review/${id}/reply`), { text })
|
|
663
|
+
runInAction(() => {
|
|
664
|
+
const { [id]: _, ...rest } = this.replies
|
|
665
|
+
this.replies = rest
|
|
666
|
+
store.put(`${this.prefix}reply.${id}`, null)
|
|
667
|
+
})
|
|
668
|
+
void this.refresh()
|
|
669
|
+
} catch (e) {
|
|
670
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
671
|
+
} finally {
|
|
672
|
+
runInAction(() => (this.busy = false))
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private async markRead(id: string): Promise<void> {
|
|
677
|
+
if (this.reading.has(id)) return
|
|
678
|
+
this.reading.add(id)
|
|
679
|
+
try {
|
|
680
|
+
await postJson(this.st.url(`/api/review/${id}/read`), {})
|
|
681
|
+
void this.refresh()
|
|
682
|
+
} catch (e) {
|
|
683
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
684
|
+
} finally {
|
|
685
|
+
this.reading.delete(id)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
toggleMarks(): void {
|
|
690
|
+
this.marksOn = !this.marksOn
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** a rectangle picked: its view comes on screen, and on a clip its frame, paused */
|
|
694
|
+
pickRect(i: number | null): void {
|
|
695
|
+
this.rect = i
|
|
696
|
+
if (i === null) return
|
|
697
|
+
const r = this.draft.rects[i]
|
|
698
|
+
this.view = r?.view ?? 0
|
|
699
|
+
if (r?.t === undefined || !this.clipKey) return
|
|
700
|
+
this.clip.seek(r.t)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
setRectComment(i: number, comment: string): void {
|
|
704
|
+
this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
removeRect(i: number): void {
|
|
708
|
+
this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
|
|
709
|
+
this.rect = null
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
setComment(comment: string): void {
|
|
713
|
+
this.edit((d) => ({ ...d, comment }))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** back to what was sent */
|
|
717
|
+
dropDraft(): void {
|
|
718
|
+
const x = this.open
|
|
719
|
+
if (!x) return
|
|
720
|
+
const { [x.item.id]: _, ...rest } = this.drafts
|
|
721
|
+
this.drafts = rest
|
|
722
|
+
store.put(`${this.prefix}draft.${x.item.id}`, null)
|
|
723
|
+
this.rect = null
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** V, F, R, A: the draft goes to the card's thread as the owner's verdict, through the server */
|
|
727
|
+
async submit(status: ReviewStatus): Promise<void> {
|
|
728
|
+
const x = this.open
|
|
729
|
+
if (!x || this.busy) return
|
|
730
|
+
if (x.item.origin.by !== 'agent') {
|
|
731
|
+
this.error = 'this item was raised, not shown by an agent: a word on it goes in a reply'
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
if (x.item.wip) {
|
|
735
|
+
this.error = 'a draft card: the verdict comes once its agent makes it ready; a word goes in a reply'
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
const id = x.item.id
|
|
739
|
+
const d = this.draft
|
|
740
|
+
this.busy = true
|
|
741
|
+
this.error = null
|
|
742
|
+
try {
|
|
743
|
+
const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [] })
|
|
744
|
+
runInAction(() => {
|
|
745
|
+
// the verdict shows at once: the list of every card comes back after it
|
|
746
|
+
const r = this.list
|
|
747
|
+
if (r) this.list = { ...r, items: r.items.map((y) => (y.item.id === id ? { ...y, thread: [...y.thread, m] } : y)) }
|
|
748
|
+
const { [id]: _, ...rest } = this.drafts
|
|
749
|
+
this.drafts = rest
|
|
750
|
+
store.put(`${this.prefix}draft.${id}`, null)
|
|
751
|
+
})
|
|
752
|
+
void this.refresh()
|
|
753
|
+
// the tree's count of what waits on him moves at once
|
|
754
|
+
void this.st.cols.poll(true)
|
|
755
|
+
} catch (e) {
|
|
756
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
757
|
+
} finally {
|
|
758
|
+
runInAction(() => (this.busy = false))
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
|
|
763
|
+
* A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame */
|
|
764
|
+
key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
|
|
765
|
+
const k = e.key.toLowerCase()
|
|
766
|
+
if (this.clipKey && e.key === ' ') this.clip.toggle()
|
|
767
|
+
else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
|
|
768
|
+
else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
|
|
769
|
+
else if (k === 'v') void this.submit('approved')
|
|
770
|
+
else if (k === 'f') void this.submit('approved-with-fix')
|
|
771
|
+
else if (k === 'r') void this.submit('rejected')
|
|
772
|
+
else if (k === 'a') void this.submit('archived')
|
|
773
|
+
else if (k === 'n') this.nextPending()
|
|
774
|
+
else if (k === 'm') this.toggleMarks()
|
|
775
|
+
else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
|
|
776
|
+
else return false
|
|
777
|
+
return true
|
|
778
|
+
}
|
|
779
|
+
}
|