@liustack/pptwise 0.22.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/LICENSE +21 -0
- package/README.md +127 -0
- package/README.zh-CN.md +136 -0
- package/cordis.patch.yml +5 -0
- package/dist/chunk-3ZUKISTY.js +114 -0
- package/dist/chunk-3ZUKISTY.js.map +1 -0
- package/dist/chunk-M35M4QUC.js +1167 -0
- package/dist/chunk-M35M4QUC.js.map +1 -0
- package/dist/chunk-VUOLBHD7.js +19 -0
- package/dist/chunk-VUOLBHD7.js.map +1 -0
- package/dist/chunk-WL5KWYKS.js +49762 -0
- package/dist/chunk-WL5KWYKS.js.map +1 -0
- package/dist/cli.js +4753 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +4224 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +7 -0
- package/dist/node.js +11 -0
- package/dist/node.js.map +1 -0
- package/dist/pixel-audit-H5K6JK3X.js +218 -0
- package/dist/pixel-audit-H5K6JK3X.js.map +1 -0
- package/dist/registry-C0GJH7ZT.d.ts +46 -0
- package/dsh/client.js +1398 -0
- package/dsh/index.js +141 -0
- package/dsh/preview-tool.js +1931 -0
- package/dsh/spawnHidden.js +109 -0
- package/package.json +113 -0
- package/skills/pptwise/SKILL.md +100 -0
- package/skills/pptwise/SKILL.zh-CN.md +102 -0
- package/skills/pptwise/references/branding.md +18 -0
- package/skills/pptwise/references/branding.zh-CN.md +21 -0
- package/skills/pptwise/references/components.md +35 -0
- package/skills/pptwise/references/components.zh-CN.md +40 -0
- package/skills/pptwise/references/density.md +17 -0
- package/skills/pptwise/references/density.zh-CN.md +22 -0
- package/skills/pptwise/references/images.md +42 -0
- package/skills/pptwise/references/images.zh-CN.md +47 -0
- package/skills/pptwise/references/layouts.md +37 -0
- package/skills/pptwise/references/layouts.zh-CN.md +42 -0
- package/skills/pptwise/references/spec.md +107 -0
- package/skills/pptwise/references/spec.zh-CN.md +112 -0
- package/skills/pptwise/references/validate.md +82 -0
- package/skills/pptwise/references/validate.zh-CN.md +87 -0
- package/skills/pptwise/scripts/run.ps1 +192 -0
- package/skills/pptwise/scripts/run.sh +229 -0
package/dsh/client.js
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
// Browser half of the pptwise dsh plugin: the deck preview card.
|
|
2
|
+
//
|
|
3
|
+
// The host half (`./preview-tool.js`) registers `pptwise_preview` and puts
|
|
4
|
+
// the rendered bundle on `output.presentationMeta` — a channel persisted with
|
|
5
|
+
// the session log and never shown to the model. This file is what turns that
|
|
6
|
+
// payload into something a person can look at: a thumbnail strip in the tool
|
|
7
|
+
// card, and a full-size modal on click. No new tab, no localhost URL, no
|
|
8
|
+
// "open this link yourself", which is the whole reason the tool exists.
|
|
9
|
+
//
|
|
10
|
+
// `dsh.client.immediately` is not optional for this plugin. The client module
|
|
11
|
+
// table is lazy: a boot-graph row only loads when something imports its id,
|
|
12
|
+
// and nothing in the shell imports a third-party plugin. Without the flag
|
|
13
|
+
// this file is served and never executed — which is exactly how the first
|
|
14
|
+
// build shipped, with the tool rendering in the generic row and no error
|
|
15
|
+
// anywhere to say the card had never been loaded at all.
|
|
16
|
+
//
|
|
17
|
+
// Hand-written in the lazy-CJS bundle protocol (`window.__ModuleLoader__.load`
|
|
18
|
+
// with a factory returning cordis-plugin exports), matching the host half's
|
|
19
|
+
// zero-dependency stance and modlens's precedent: no build step, no JSX, no
|
|
20
|
+
// imports from dsh client packages beyond what `require` hands back at
|
|
21
|
+
// materialization time. `react` arrives through that same `require`, so this
|
|
22
|
+
// package still declares no react dependency of its own.
|
|
23
|
+
//
|
|
24
|
+
// It renders nothing itself. Every slide it shows is markup `renderSlideSvg`
|
|
25
|
+
// already produced, carried verbatim from the tool result — the same single
|
|
26
|
+
// rendering path the CLI, the review gallery and the promotional images all
|
|
27
|
+
// read from. A second renderer living in a UI is how those would drift into
|
|
28
|
+
// describing different products.
|
|
29
|
+
//
|
|
30
|
+
// The two halves of a card have different lifetimes, and that is the whole of
|
|
31
|
+
// what went wrong in the field. What it draws comes from one request; what its
|
|
32
|
+
// buttons do is a second request made minutes or days later, against files the
|
|
33
|
+
// user may since have deleted. Reopen a transcript after that and the card used
|
|
34
|
+
// to have two failure modes and no words for either: under Code Mode the fetch
|
|
35
|
+
// 404'd and the entire card — strip, Open, Download — rendered nothing, so the
|
|
36
|
+
// tool call read as if it had produced nothing; with a payload already in hand,
|
|
37
|
+
// the strip drew perfectly and the viewer opened on
|
|
38
|
+
// `{"error":"unknown preview id"}`, displayed as a document, framed by this
|
|
39
|
+
// card's own Close button. So: a missing deck is now an answer (the card says
|
|
40
|
+
// the deck is gone, and how long it was), every way into the viewer asks the
|
|
41
|
+
// route first, and the host half answers the iframe with a page rather than
|
|
42
|
+
// with JSON.
|
|
43
|
+
//
|
|
44
|
+
// The correction after that was learning to tell "gone" from "this request did
|
|
45
|
+
// not work" — see `verdictOf`. Collapsing the two meant one flaky response
|
|
46
|
+
// permanently retired a deck that was sitting on disk the whole time.
|
|
47
|
+
//
|
|
48
|
+
// It also *views* nothing of its own any more. The modal used to be a small
|
|
49
|
+
// React slideshow — arrow keys, a page counter, prev/next buttons, a stand-in
|
|
50
|
+
// for pages the server had declined to send — sitting next to the finished
|
|
51
|
+
// `preview.html` that `pptwise preview --html` writes for every run and that
|
|
52
|
+
// harnesses without a plugin UI are told to open. Two viewers, one deck, and
|
|
53
|
+
// only one of them tested. The modal is now an iframe pointing at that file,
|
|
54
|
+
// and keeps only the two things the file cannot do for itself: close, and hand
|
|
55
|
+
// over the .pptx.
|
|
56
|
+
window.__ModuleLoader__.load({
|
|
57
|
+
id: '@liustack/pptwise',
|
|
58
|
+
factory: (require) => {
|
|
59
|
+
var module = { exports: {} }
|
|
60
|
+
var exports = module.exports
|
|
61
|
+
|
|
62
|
+
var TOOL_NAME = 'pptwise_preview'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How many thumbnails the strip draws.
|
|
66
|
+
*
|
|
67
|
+
* The host half inlines exactly this many pages' markup
|
|
68
|
+
* (`THUMBNAIL_STRIP_PAGES`, ./preview-tool.js) and sends the rest as
|
|
69
|
+
* metadata only, so the two numbers are one decision written down twice.
|
|
70
|
+
* `stripPages` filters on markup as well as slicing, which is what keeps a
|
|
71
|
+
* disagreement between them showing up as a shorter strip instead of a row
|
|
72
|
+
* of empty boxes.
|
|
73
|
+
*/
|
|
74
|
+
var STRIP_PAGES = 12
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where the full-size viewer lives — the html `preview --html` wrote.
|
|
78
|
+
*
|
|
79
|
+
* `#page=N` is that page's own way in, and a URL is the only thing this
|
|
80
|
+
* card can hand it: the frame is a document, not a component with props.
|
|
81
|
+
* N counts pages the way a reader does, so it is the thumbnail's own
|
|
82
|
+
* label rather than its index in a strip that may be a prefix of the deck.
|
|
83
|
+
*/
|
|
84
|
+
function previewHtmlUrl(previewId, startPage) {
|
|
85
|
+
var url = '/pptwise/preview/' + previewId + '/html'
|
|
86
|
+
return startPage > 1 ? url + '#page=' + startPage : url
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where the deck itself lives — the bundle the strip draws from. */
|
|
90
|
+
function previewBundleUrl(previewId) {
|
|
91
|
+
return '/pptwise/preview/' + previewId
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What one answer from the route says about the deck behind an id.
|
|
96
|
+
*
|
|
97
|
+
* This is the distinction the card was missing, and the bug it caused was
|
|
98
|
+
* not subtle: every non-2xx response collapsed into "expired", so a single
|
|
99
|
+
* 502 from a restarting harness, or a request that never left the tab,
|
|
100
|
+
* retired a deck that was sitting on disk the whole time — permanently,
|
|
101
|
+
* for the life of the page, with a reload the only way back.
|
|
102
|
+
*
|
|
103
|
+
* 404 and 410 are the route's two verdicts about the id itself: never
|
|
104
|
+
* heard of it, or heard of it and cannot serve what is left. Both are
|
|
105
|
+
* final, because nothing regenerates a preview (see the host half). Every
|
|
106
|
+
* other outcome — a 5xx, a proxy error, a socket that never opened — is a
|
|
107
|
+
* statement about this attempt and about nothing else, so the card holds
|
|
108
|
+
* its judgement and the next click tries again.
|
|
109
|
+
*/
|
|
110
|
+
function verdictOf(res) {
|
|
111
|
+
if (!res) return Promise.resolve(UNREACHABLE_VERDICT)
|
|
112
|
+
if (res.ok) return Promise.resolve('alive')
|
|
113
|
+
// A status code does not say who produced it. The plugin's route may
|
|
114
|
+
// have failed to register, a proxy may have answered first, or the shell
|
|
115
|
+
// may be serving its own not-found page for a path it does not know —
|
|
116
|
+
// all of which look exactly like our 404 and none of which are evidence
|
|
117
|
+
// that a deck is gone. Only a stamped answer is allowed to retire one.
|
|
118
|
+
//
|
|
119
|
+
// Same-origin, so the header is readable without the server having to
|
|
120
|
+
// list it in `Access-Control-Expose-Headers`. If this route ever became
|
|
121
|
+
// cross-origin that header would have to be added, and until then a proxy
|
|
122
|
+
// that strips ours degrades everything to `unreachable` — a false
|
|
123
|
+
// negative, which is the safe direction: the card offers another try
|
|
124
|
+
// instead of retiring a live deck.
|
|
125
|
+
if (!fromPreviewRoute(res)) return Promise.resolve(UNREACHABLE_VERDICT)
|
|
126
|
+
// Not lumped in with the retryable failures, because retrying an
|
|
127
|
+
// identical request against a door that is closed does nothing. The user
|
|
128
|
+
// has to change something outside this card first.
|
|
129
|
+
//
|
|
130
|
+
// Only a *stamped* 401/403 reaches this line — the check above has
|
|
131
|
+
// already sent an unstamped one to `unreachable`. So this is a narrow
|
|
132
|
+
// door: a plain reverse proxy's own 401 does not carry our header and
|
|
133
|
+
// gets a Retry it may well be able to satisfy, which is the safe way
|
|
134
|
+
// round. The host half sends no 401/403 of its own at all.
|
|
135
|
+
if (res.status === 401 || res.status === 403) return Promise.resolve('refused')
|
|
136
|
+
if (res.status !== 404 && res.status !== 410) return Promise.resolve(UNREACHABLE_VERDICT)
|
|
137
|
+
// Both final, and they need opposite sentences in front of a person, so
|
|
138
|
+
// the status alone is not enough to act on. The route says which kind in
|
|
139
|
+
// the body; reading it is why this function is asynchronous.
|
|
140
|
+
return readFailureCode(res).then(function (code) {
|
|
141
|
+
return code === 'preview_damaged' ? 'damaged' : 'gone'
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The header the host half stamps on every answer it writes. */
|
|
146
|
+
var ROUTE_HEADER = 'x-pptwise-preview'
|
|
147
|
+
|
|
148
|
+
function fromPreviewRoute(res) {
|
|
149
|
+
var headers = res && res.headers
|
|
150
|
+
if (!headers || typeof headers.get !== 'function') return false
|
|
151
|
+
return headers.get(ROUTE_HEADER) === '1'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The machine-readable half of a failure, or nothing.
|
|
156
|
+
*
|
|
157
|
+
* Read from the body rather than guessed from the prose. A body that will
|
|
158
|
+
* not parse is not a reason to give up on the status we already have, so
|
|
159
|
+
* this resolves with null and the caller falls back to the coarser reading.
|
|
160
|
+
*/
|
|
161
|
+
function readFailureCode(res) {
|
|
162
|
+
if (!res || typeof res.json !== 'function') return Promise.resolve(null)
|
|
163
|
+
return res
|
|
164
|
+
.json()
|
|
165
|
+
.then(function (body) {
|
|
166
|
+
return body && typeof body.code === 'string' ? body.code : null
|
|
167
|
+
})
|
|
168
|
+
.catch(function () {
|
|
169
|
+
return null
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A request that never produced a response says nothing about the deck. */
|
|
174
|
+
var UNREACHABLE_VERDICT = 'unreachable'
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The verdicts a Retry button can honestly do something about.
|
|
178
|
+
*
|
|
179
|
+
* Only `unreachable`. `gone` and `damaged` are final by construction —
|
|
180
|
+
* nothing regenerates a preview — and `refused` is the route answering, so
|
|
181
|
+
* the identical request will be refused identically until something changes
|
|
182
|
+
* elsewhere. This is called from the render path, not just asserted about:
|
|
183
|
+
* a version of this that was only ever tested as a pure function shipped a
|
|
184
|
+
* Retry button on a refusal it could never satisfy.
|
|
185
|
+
*/
|
|
186
|
+
function isRetryable(verdict) {
|
|
187
|
+
return verdict === UNREACHABLE_VERDICT
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The verdicts that mean this deck will not open, whatever the user does next. */
|
|
191
|
+
function isFinal(verdict) {
|
|
192
|
+
return verdict === 'gone' || verdict === 'damaged' || verdict === 'refused'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Are these bytes a zip, and therefore plausibly a .pptx?
|
|
197
|
+
*
|
|
198
|
+
* `50 4b 03 04` is the local file header every zip starts with, and Office
|
|
199
|
+
* Open XML is a zip. This is not a deep validation and does not try to be —
|
|
200
|
+
* it is the one check that separates "a deck" from "an HTML page a proxy
|
|
201
|
+
* returned with status 200", which is the failure that actually happened.
|
|
202
|
+
*/
|
|
203
|
+
function isZip(blob) {
|
|
204
|
+
if (!blob || typeof blob.slice !== 'function' || typeof blob.arrayBuffer !== 'function') {
|
|
205
|
+
return Promise.resolve(false)
|
|
206
|
+
}
|
|
207
|
+
return blob
|
|
208
|
+
.slice(0, 4)
|
|
209
|
+
.arrayBuffer()
|
|
210
|
+
.then(function (head) {
|
|
211
|
+
var bytes = new Uint8Array(head)
|
|
212
|
+
return bytes.length === 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
|
|
213
|
+
})
|
|
214
|
+
.catch(function () {
|
|
215
|
+
return false
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What the card says once it knows the deck behind its id is gone.
|
|
221
|
+
*
|
|
222
|
+
* One sentence, used by the badge, the export button and the degraded row
|
|
223
|
+
* alike, because they are all reporting the same fact and a card that
|
|
224
|
+
* words it three ways reads as three different problems.
|
|
225
|
+
*
|
|
226
|
+
* It no longer says "expired", and that is a correctness fix rather than a
|
|
227
|
+
* wording preference: nothing expires a preview. There is no timer, no
|
|
228
|
+
* size budget and no sweep, so telling the user their preview timed out
|
|
229
|
+
* sent them looking for a retention setting that does not exist.
|
|
230
|
+
*
|
|
231
|
+
* It does not name a culprit either, and that is the same correction made
|
|
232
|
+
* twice. All the route observed is that nothing is at this id under the
|
|
233
|
+
* previews root this session is reading. Deletion is the usual reason and
|
|
234
|
+
* not the only one: `PPTWISE_HOME` decides that root, so a deck written
|
|
235
|
+
* under a different one is alive and out of reach, which is a fact about
|
|
236
|
+
* configuration and not about anything the user did wrong.
|
|
237
|
+
*/
|
|
238
|
+
var MISSING_HINT =
|
|
239
|
+
'The rendered deck for this preview is no longer on disk where this session looks for it. ' +
|
|
240
|
+
'Previews are never removed on a timer, so it was either deleted or written under a different ' +
|
|
241
|
+
'PPTWISE_HOME. Run pptwise_preview again to rebuild it.'
|
|
242
|
+
|
|
243
|
+
/** What the card says when it could not reach the route at all. */
|
|
244
|
+
var UNREACHABLE_HINT =
|
|
245
|
+
'Could not reach the pptwise preview route. The deck may still be there — try again.'
|
|
246
|
+
|
|
247
|
+
/** What the card says when the route answered, but refused. */
|
|
248
|
+
var REFUSED_HINT =
|
|
249
|
+
'The pptwise preview route refused this request. The deck may still be there, ' +
|
|
250
|
+
'but this browser is not allowed to read it. Trying again will be refused the same way.'
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What the card says when the deck was rendered and its bookkeeping is not
|
|
254
|
+
* readable.
|
|
255
|
+
*
|
|
256
|
+
* Deliberately never the word "deleted", and never "no longer on disk". The
|
|
257
|
+
* host half tells these two apart and says so in a machine-readable code;
|
|
258
|
+
* before this sentence existed the card threw that away and reported both
|
|
259
|
+
* as a deletion, so a `record.json` truncated by a full disk arrived at the
|
|
260
|
+
* user as an accusation that they had removed their own work. The pages are
|
|
261
|
+
* very likely still sitting next to the file that went bad.
|
|
262
|
+
*/
|
|
263
|
+
var DAMAGED_HINT =
|
|
264
|
+
'This preview cannot be opened: the file describing it is present but unreadable. ' +
|
|
265
|
+
'The rendered pages may still be on disk — run pptwise_preview again to rebuild it.'
|
|
266
|
+
|
|
267
|
+
/** The sentence that goes with a verdict, in the one place that shows all of them. */
|
|
268
|
+
function hintFor(verdict) {
|
|
269
|
+
if (verdict === 'gone') return MISSING_HINT
|
|
270
|
+
if (verdict === 'damaged') return DAMAGED_HINT
|
|
271
|
+
if (verdict === 'refused') return REFUSED_HINT
|
|
272
|
+
return UNREACHABLE_HINT
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The one word the badge shows, per verdict. Never "gone" for anything but gone. */
|
|
276
|
+
function badgeFor(verdict) {
|
|
277
|
+
if (verdict === 'damaged') return 'damaged'
|
|
278
|
+
if (verdict === 'refused') return 'refused'
|
|
279
|
+
return 'gone'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What the export button says once it knows it cannot succeed. */
|
|
283
|
+
function exportLabelFor(verdict) {
|
|
284
|
+
if (verdict === 'damaged') return 'Damaged'
|
|
285
|
+
if (verdict === 'refused') return 'Refused'
|
|
286
|
+
return 'Unavailable'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Everything the tool said in text, joined.
|
|
291
|
+
*
|
|
292
|
+
* This is the channel that survives Code Mode: a tool invoked from inside
|
|
293
|
+
* `run_code` is a sub-call, and the registry computes `presentationMeta`
|
|
294
|
+
* for top-level calls only, so on this repo's default agent preset the
|
|
295
|
+
* structured payload never exists. The result text always does — which is
|
|
296
|
+
* why both the preview id and, when the deck itself is gone, the one fact
|
|
297
|
+
* left about it are read back out of here.
|
|
298
|
+
*/
|
|
299
|
+
function resultTextOf(block) {
|
|
300
|
+
var text = ''
|
|
301
|
+
var content = (block && (block.content || (block.result && block.result.content))) || []
|
|
302
|
+
for (var i = 0; i < content.length; i++) {
|
|
303
|
+
if (content[i] && typeof content[i].text === 'string') text += content[i].text
|
|
304
|
+
}
|
|
305
|
+
return text
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The preview id the tool stamped into its own result text. */
|
|
309
|
+
function previewIdOf(block) {
|
|
310
|
+
var m = /pptwise-preview:([A-Za-z0-9-]+)/.exec(resultTextOf(block))
|
|
311
|
+
return m ? m[1] : null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* How long the deck was, read back out of the tool's own summary line.
|
|
316
|
+
*
|
|
317
|
+
* The tool's summary line is what survives in a transcript once the
|
|
318
|
+
* rendered files are gone, and this is the fact the card reads back out of
|
|
319
|
+
* it. The bundle does not survive: it never enters the session log under
|
|
320
|
+
* Code Mode, and putting it there would mean a session log that grows by a
|
|
321
|
+
* deck's worth of SVG per call — the trade this plugin already decided
|
|
322
|
+
* against. So when the route can no longer serve the deck, one number
|
|
323
|
+
* parsed from a line already in the log is the difference between a card
|
|
324
|
+
* that says "there were nine pages here" and a card that vanishes as if
|
|
325
|
+
* the tool had never run.
|
|
326
|
+
*
|
|
327
|
+
* Parsed rather than carried in a second machine-readable stamp: the
|
|
328
|
+
* sentence is written by `modelSummary` in this plugin's own host half,
|
|
329
|
+
* one file away, and a test drives the real output of that function
|
|
330
|
+
* through this parser, so the two cannot drift apart without that test
|
|
331
|
+
* going red.
|
|
332
|
+
*/
|
|
333
|
+
function pageCountOf(block) {
|
|
334
|
+
var m = /\brendered (\d+) pages?\b/.exec(resultTextOf(block))
|
|
335
|
+
return m ? Number(m[1]) : null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pull the preview bundle out of a frozen tool-call node.
|
|
340
|
+
*
|
|
341
|
+
* `presentationMeta` is projected into the result's view/meta by the
|
|
342
|
+
* host; the exact field the runtime lands it on has moved between rc
|
|
343
|
+
* builds, so this reads the handful of shapes it can appear under rather
|
|
344
|
+
* than pinning one. Returning null simply falls through to the route,
|
|
345
|
+
* which is where a Code Mode sub-call's deck has to come from anyway.
|
|
346
|
+
*/
|
|
347
|
+
function bundleOf(block) {
|
|
348
|
+
if (!block) return null
|
|
349
|
+
var candidates = [block.meta, block.resultView, block.result && block.result.meta, block.presentationMeta]
|
|
350
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
351
|
+
var c = candidates[i]
|
|
352
|
+
if (c && c.card === 'pptwise-preview' && c.bundle && Array.isArray(c.bundle.pages)) return c.bundle
|
|
353
|
+
if (c && c.bundle && Array.isArray(c.bundle.pages)) return c.bundle
|
|
354
|
+
}
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Every page the deck has, markup or not.
|
|
360
|
+
*
|
|
361
|
+
* The pages past the strip arrive with `svg: null`, and they still belong
|
|
362
|
+
* here: this is what the card counts and what decides whether there is a
|
|
363
|
+
* deck at all. Filtering them out would make the header under-report a
|
|
364
|
+
* long deck's length, and would make a card vanish rather than say
|
|
365
|
+
* anything if a bundle ever arrived carrying no markup at all.
|
|
366
|
+
*/
|
|
367
|
+
function viewablePages(bundle) {
|
|
368
|
+
return bundle && Array.isArray(bundle.pages) ? bundle.pages : []
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function hasMarkup(page) {
|
|
372
|
+
return !!page && typeof page.svg === 'string' && page.svg.length > 0
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The pages the strip can actually draw: the first `STRIP_PAGES`, minus
|
|
377
|
+
* any that arrived without markup. See `STRIP_PAGES` for why the filter is
|
|
378
|
+
* normally a no-op and why it is here anyway.
|
|
379
|
+
*/
|
|
380
|
+
function stripPages(pages) {
|
|
381
|
+
return pages.slice(0, STRIP_PAGES).filter(hasMarkup)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The number a reader would call this page, not its index in the array. */
|
|
385
|
+
function pageNumberOf(page, index) {
|
|
386
|
+
return (page && typeof page.page === 'number' ? page.page : null) || index + 1
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Namespace one page's internal ids.
|
|
391
|
+
*
|
|
392
|
+
* Every slide is a standalone SVG document whose ids only have to be
|
|
393
|
+
* unique inside itself. Several of them in one page merge id spaces, so a
|
|
394
|
+
* `url(#decor-tech-field)` on slide 6 would resolve against slide 3's
|
|
395
|
+
* definition of the same id. The host half solves this at build time for
|
|
396
|
+
* the files it writes (`src/lib/svg-ids.ts`); the same transform has to
|
|
397
|
+
* run here because this card mounts several documents into one DOM.
|
|
398
|
+
*/
|
|
399
|
+
function namespaceIds(svg, prefix) {
|
|
400
|
+
return svg
|
|
401
|
+
.replace(/\bid="([^"]*)"/g, function (_m, id) {
|
|
402
|
+
return 'id="' + prefix + id + '"'
|
|
403
|
+
})
|
|
404
|
+
.replace(/url\(#([^)"']*)\)/g, function (_m, id) {
|
|
405
|
+
return 'url(#' + prefix + id + ')'
|
|
406
|
+
})
|
|
407
|
+
.replace(/\b(xlink:href|href)="#([^"]*)"/g, function (_m, attr, id) {
|
|
408
|
+
return attr + '="#' + prefix + id + '"'
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function PreviewCard(react) {
|
|
413
|
+
var h = react.createElement
|
|
414
|
+
var useState = react.useState
|
|
415
|
+
var useEffect = react.useEffect
|
|
416
|
+
|
|
417
|
+
var COLORS = {
|
|
418
|
+
line: 'var(--dsw-alias-separator, rgba(127,127,127,0.28))',
|
|
419
|
+
dim: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.85))',
|
|
420
|
+
text: 'var(--dsw-alias-label-primary, inherit)',
|
|
421
|
+
stage: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))',
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The viewer's own two buttons, styled in a sheet rather than inline.
|
|
426
|
+
*
|
|
427
|
+
* Everything else in this file styles elements directly, which is fine
|
|
428
|
+
* until a button has to answer the pointer: a solid white button that
|
|
429
|
+
* does not change under the cursor reads as broken before it reads as
|
|
430
|
+
* plain, and `:hover` cannot be written inline at all.
|
|
431
|
+
*
|
|
432
|
+
* The colours have to live here too, not just the hover rules. An
|
|
433
|
+
* element's own `style` attribute outranks any sheet, so leaving
|
|
434
|
+
* `background` and `border` inline lets them win every hover — the
|
|
435
|
+
* transition still runs, which is what makes the result look wired up
|
|
436
|
+
* while the colour never moves. The alternative is `!important` on four
|
|
437
|
+
* declarations, which is the same bug with an override on top.
|
|
438
|
+
*
|
|
439
|
+
* Sized against the harness's shell rather than the card's: these sit
|
|
440
|
+
* over a full-screen view, so at the card's 13px/23px they read as
|
|
441
|
+
* smaller than the controls they are covering. Solid marks the errand —
|
|
442
|
+
* taking the deck away is why the row exists — and the outline marks
|
|
443
|
+
* the exit.
|
|
444
|
+
*/
|
|
445
|
+
var MODAL_STYLE_ID = 'pptwise-modal-style'
|
|
446
|
+
var MODAL_BTN_BASE =
|
|
447
|
+
'font:inherit;font-size:13px;padding:7px 16px;border-radius:8px;cursor:pointer;'
|
|
448
|
+
function ensureModalStyles() {
|
|
449
|
+
if (document.getElementById(MODAL_STYLE_ID)) return
|
|
450
|
+
var el = document.createElement('style')
|
|
451
|
+
el.id = MODAL_STYLE_ID
|
|
452
|
+
el.textContent =
|
|
453
|
+
'.pf-mbtn{' +
|
|
454
|
+
MODAL_BTN_BASE +
|
|
455
|
+
'border:1px solid rgba(255,255,255,0.5);background:transparent;color:#fff;' +
|
|
456
|
+
'transition:background-color .12s,border-color .12s}' +
|
|
457
|
+
'.pf-mbtn:hover{background:rgba(255,255,255,0.14);border-color:rgba(255,255,255,0.8)}' +
|
|
458
|
+
'.pf-mbtn-primary{' +
|
|
459
|
+
MODAL_BTN_BASE +
|
|
460
|
+
'font-weight:600;border:1px solid transparent;background:#fff;color:#111;' +
|
|
461
|
+
'transition:background-color .12s}' +
|
|
462
|
+
'.pf-mbtn-primary:hover{background:#e2e2e2}'
|
|
463
|
+
document.head.appendChild(el)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** One slide, mounted as real SVG so it scales with its box. */
|
|
467
|
+
function Slide(props) {
|
|
468
|
+
var ref = react.useRef(null)
|
|
469
|
+
useEffect(
|
|
470
|
+
function () {
|
|
471
|
+
var el = ref.current
|
|
472
|
+
if (!el) return
|
|
473
|
+
el.innerHTML = namespaceIds(props.svg, props.prefix)
|
|
474
|
+
var svg = el.querySelector('svg')
|
|
475
|
+
if (svg) {
|
|
476
|
+
svg.setAttribute('width', '100%')
|
|
477
|
+
svg.setAttribute('height', '100%')
|
|
478
|
+
svg.style.display = 'block'
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
[props.svg, props.prefix],
|
|
482
|
+
)
|
|
483
|
+
return h('div', {
|
|
484
|
+
ref: ref,
|
|
485
|
+
style: { position: 'absolute', inset: 0 },
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** One thumbnail: a 16:9 box holding the slide, clickable into the viewer. */
|
|
490
|
+
function Frame(props) {
|
|
491
|
+
return h(
|
|
492
|
+
'div',
|
|
493
|
+
{
|
|
494
|
+
style: {
|
|
495
|
+
position: 'relative',
|
|
496
|
+
aspectRatio: (props.width || 1280) + ' / ' + (props.height || 720),
|
|
497
|
+
background: COLORS.stage,
|
|
498
|
+
borderRadius: 6,
|
|
499
|
+
overflow: 'hidden',
|
|
500
|
+
border: '1px solid ' + COLORS.line,
|
|
501
|
+
cursor: props.onClick ? 'zoom-in' : 'default',
|
|
502
|
+
width: '100%',
|
|
503
|
+
},
|
|
504
|
+
onClick: props.onClick,
|
|
505
|
+
role: props.onClick ? 'button' : undefined,
|
|
506
|
+
tabIndex: props.onClick ? 0 : undefined,
|
|
507
|
+
onKeyDown: props.onClick
|
|
508
|
+
? function (e) {
|
|
509
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
510
|
+
e.preventDefault()
|
|
511
|
+
props.onClick()
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
: undefined,
|
|
515
|
+
title: props.title,
|
|
516
|
+
},
|
|
517
|
+
h(Slide, { svg: props.page.svg, prefix: props.prefix }),
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Full-size viewer: the deck's own `preview.html`, in an iframe.
|
|
523
|
+
*
|
|
524
|
+
* Everything a reader does inside it — ←/→, the filmstrip, the
|
|
525
|
+
* light/dark surround, the audit findings panel — belongs to that page,
|
|
526
|
+
* which was written, tested and shipped for the harnesses that have no
|
|
527
|
+
* plugin UI. This modal contributes the two things the page has no way
|
|
528
|
+
* to offer from inside itself: a way out, and the export.
|
|
529
|
+
*/
|
|
530
|
+
function Modal(props) {
|
|
531
|
+
var frameRef = react.useRef(null)
|
|
532
|
+
|
|
533
|
+
useEffect(
|
|
534
|
+
function () {
|
|
535
|
+
ensureModalStyles()
|
|
536
|
+
function onKey(e) {
|
|
537
|
+
if (e.key === 'Escape') props.onClose()
|
|
538
|
+
}
|
|
539
|
+
document.addEventListener('keydown', onKey)
|
|
540
|
+
// Once the reader clicks the deck, keystrokes go to the iframe's
|
|
541
|
+
// own document and never reach this one — so Escape has to be
|
|
542
|
+
// heard there too, or it stops working exactly when the modal is
|
|
543
|
+
// in use. The frame is same-origin by construction (its src is
|
|
544
|
+
// this plugin's own route), and the access is guarded anyway:
|
|
545
|
+
// losing Escape is not worth throwing inside an effect for. The
|
|
546
|
+
// listener needs no removal of its own, since it dies with the
|
|
547
|
+
// document when this modal unmounts.
|
|
548
|
+
var frame = frameRef.current
|
|
549
|
+
function listenInside() {
|
|
550
|
+
try {
|
|
551
|
+
var doc = frame.contentDocument
|
|
552
|
+
if (doc) doc.addEventListener('keydown', onKey)
|
|
553
|
+
} catch {
|
|
554
|
+
/* not reachable from here after all — the outer listener stands */
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (frame) frame.addEventListener('load', listenInside)
|
|
558
|
+
return function () {
|
|
559
|
+
document.removeEventListener('keydown', onKey)
|
|
560
|
+
if (frame) frame.removeEventListener('load', listenInside)
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
[props.onClose, props.src],
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
return h(
|
|
567
|
+
'div',
|
|
568
|
+
{
|
|
569
|
+
style: {
|
|
570
|
+
position: 'fixed',
|
|
571
|
+
inset: 0,
|
|
572
|
+
zIndex: 9999,
|
|
573
|
+
// Opaque, not the usual translucent backdrop. This covers the
|
|
574
|
+
// whole viewport with a document, so there is nothing behind it
|
|
575
|
+
// worth a glimpse of — and a glimpse is all a translucent one
|
|
576
|
+
// gives. At 0.8 the harness's own composer stayed legible right
|
|
577
|
+
// under this modal's buttons and the eye read the two as one
|
|
578
|
+
// strip of controls; at 0.94 its title bar and its `Session
|
|
579
|
+
// log` pill still ghosted through at 6% white, the pill landing
|
|
580
|
+
// 14px outside the edge these buttons are aligned to.
|
|
581
|
+
background: '#000',
|
|
582
|
+
display: 'flex',
|
|
583
|
+
alignItems: 'center',
|
|
584
|
+
justifyContent: 'center',
|
|
585
|
+
padding: 24,
|
|
586
|
+
},
|
|
587
|
+
onClick: function (e) {
|
|
588
|
+
if (e.target === e.currentTarget) props.onClose()
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
h(
|
|
592
|
+
'div',
|
|
593
|
+
{
|
|
594
|
+
style: {
|
|
595
|
+
width: 'min(100%, 1600px)',
|
|
596
|
+
height: '100%',
|
|
597
|
+
display: 'flex',
|
|
598
|
+
flexDirection: 'column',
|
|
599
|
+
gap: 14,
|
|
600
|
+
},
|
|
601
|
+
onClick: function (e) {
|
|
602
|
+
if (e.target === e.currentTarget) props.onClose()
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
// Above the frame, not below it. Below, this row lands in the
|
|
606
|
+
// strip of backdrop the harness's own input area occupies, and
|
|
607
|
+
// whatever the backdrop lets through sits on the same baseline.
|
|
608
|
+
h(
|
|
609
|
+
'div',
|
|
610
|
+
{
|
|
611
|
+
style: {
|
|
612
|
+
display: 'flex',
|
|
613
|
+
alignItems: 'center',
|
|
614
|
+
justifyContent: 'flex-end',
|
|
615
|
+
gap: 10,
|
|
616
|
+
flex: '0 0 auto',
|
|
617
|
+
// Lines this row's right edge up with the Light/Dark toggle
|
|
618
|
+
// in the previewed page's own header, which sits one row
|
|
619
|
+
// below it. That page's own `header` rule reserves the same
|
|
620
|
+
// inset (`padding:11px 18px`, preview-html.ts); without
|
|
621
|
+
// matching it, two right-aligned rows 14px apart miss each
|
|
622
|
+
// other by just enough to look like a mistake, not a choice.
|
|
623
|
+
paddingRight: 18,
|
|
624
|
+
},
|
|
625
|
+
onClick: function (e) {
|
|
626
|
+
if (e.target === e.currentTarget) props.onClose()
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
props.previewId
|
|
630
|
+
? h(ExportButton, {
|
|
631
|
+
previewId: props.previewId,
|
|
632
|
+
name: props.name,
|
|
633
|
+
draft: props.draft,
|
|
634
|
+
status: props.downloadStatus,
|
|
635
|
+
onStatus: props.onDownloadStatus,
|
|
636
|
+
className: 'pf-mbtn-primary',
|
|
637
|
+
})
|
|
638
|
+
: null,
|
|
639
|
+
h(
|
|
640
|
+
'button',
|
|
641
|
+
{ onClick: props.onClose, className: 'pf-mbtn' },
|
|
642
|
+
'Close',
|
|
643
|
+
),
|
|
644
|
+
),
|
|
645
|
+
h('iframe', {
|
|
646
|
+
ref: frameRef,
|
|
647
|
+
src: props.src,
|
|
648
|
+
title: 'pptwise deck preview',
|
|
649
|
+
style: {
|
|
650
|
+
// Takes the rest of the dialog and lets the page inside do its
|
|
651
|
+
// own layout. Its stage carries `aspect-ratio: 16/9` and sizes
|
|
652
|
+
// itself off the viewport it is handed, so the slide keeps its
|
|
653
|
+
// shape at any frame size. Pinning the *frame* to 16:9 instead
|
|
654
|
+
// would have to reserve room for that page's header and
|
|
655
|
+
// filmstrip, and every guess at how much is wrong the moment
|
|
656
|
+
// either one changes height.
|
|
657
|
+
flex: '1 1 auto',
|
|
658
|
+
width: '100%',
|
|
659
|
+
minHeight: 0,
|
|
660
|
+
border: 0,
|
|
661
|
+
borderRadius: 8,
|
|
662
|
+
background: COLORS.stage,
|
|
663
|
+
},
|
|
664
|
+
}),
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The export.
|
|
671
|
+
*
|
|
672
|
+
* Fetched and saved by hand rather than left to a plain `download`
|
|
673
|
+
* anchor. A preview id can genuinely stop resolving — the user is free
|
|
674
|
+
* to delete what it names — and on a bare anchor that failure arrives as
|
|
675
|
+
* a 404 body the browser saves as a file, which is how the first version
|
|
676
|
+
* handed people a `pptx.json` and called it a download. Going through
|
|
677
|
+
* fetch means a failure can say so.
|
|
678
|
+
*
|
|
679
|
+
* The states are the verdicts, plus `busy` and `failed`. Anything final
|
|
680
|
+
* (`gone`, `damaged`, `refused`) disables the button and says which;
|
|
681
|
+
* `failed` is this attempt not working and leaves it clickable. Before
|
|
682
|
+
* that split, one dropped request turned a live export into a dead button
|
|
683
|
+
* for the rest of the session.
|
|
684
|
+
*
|
|
685
|
+
* This component owns no state. It reads `props.status` and reports
|
|
686
|
+
* through `props.onStatus(next, order)`, both of which belong to the
|
|
687
|
+
* card's store — see THE STORE for why a component keeping "just a
|
|
688
|
+
* little" of its own is how this went wrong twice. The `order` it passes
|
|
689
|
+
* back is one ticket for the whole attempt, in the download lane. Two
|
|
690
|
+
* clicks can still answer out of order, of course. What the ticket buys
|
|
691
|
+
* is that the older answer is the one discarded, and that an answer about
|
|
692
|
+
* the deck cannot discard this one.
|
|
693
|
+
*/
|
|
694
|
+
function ExportButton(props) {
|
|
695
|
+
// `props.verdict` is the card telling this button what it already
|
|
696
|
+
// learned from the route. Before it, the only way to find out was to
|
|
697
|
+
// click and wait for the fetch to fail, so a card that knew perfectly
|
|
698
|
+
// well the export was dead still offered it as `Download .pptx`.
|
|
699
|
+
var status = isFinal(props.verdict) ? props.verdict : props.status
|
|
700
|
+
var setStatus = props.onStatus
|
|
701
|
+
|
|
702
|
+
function save() {
|
|
703
|
+
if (status === 'busy' || isFinal(status)) return
|
|
704
|
+
// One ticket for this whole attempt, in its own lane. Two clicks race
|
|
705
|
+
// the same way two Opens do, and the download lane is separate from
|
|
706
|
+
// the deck lane so a slow export cannot make a fresh verdict look
|
|
707
|
+
// stale — or the reverse.
|
|
708
|
+
var order = { lane: 'download', ticket: nextTicket() }
|
|
709
|
+
setStatus('busy', order)
|
|
710
|
+
fetch('/pptwise/preview/' + props.previewId + '/pptx')
|
|
711
|
+
.then(function (res) {
|
|
712
|
+
return verdictOf(res).then(function (verdict) {
|
|
713
|
+
if (verdict !== 'alive') {
|
|
714
|
+
var error = new Error('preview route answered ' + (res ? res.status : 'nothing'))
|
|
715
|
+
error.verdict = verdict
|
|
716
|
+
throw error
|
|
717
|
+
}
|
|
718
|
+
return res.blob()
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
.then(function (blob) {
|
|
722
|
+
// Look at the bytes before saving them. A 200 whose body is a
|
|
723
|
+
// login page, a proxy notice or an error document is exactly how
|
|
724
|
+
// the first version of this button handed people a `pptx.json`
|
|
725
|
+
// and called it a download — the status line said fine and the
|
|
726
|
+
// content was not a deck. A .pptx is a zip, so its first four
|
|
727
|
+
// bytes are the only claim worth checking.
|
|
728
|
+
return isZip(blob).then(function (ok) {
|
|
729
|
+
if (!ok) {
|
|
730
|
+
var error = new Error('the preview route returned something that is not a .pptx')
|
|
731
|
+
error.verdict = UNREACHABLE_VERDICT
|
|
732
|
+
throw error
|
|
733
|
+
}
|
|
734
|
+
return blob
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
.then(function (blob) {
|
|
738
|
+
var url = URL.createObjectURL(blob)
|
|
739
|
+
var a = document.createElement('a')
|
|
740
|
+
a.href = url
|
|
741
|
+
// Matches the name the host half gave the file it is serving
|
|
742
|
+
// (`exportName`, preview-tool.js): a deck with unfilled pages
|
|
743
|
+
// must not be saved under a name that reads as finished work.
|
|
744
|
+
a.download = (props.name || 'deck') + (props.draft ? '-draft' : '') + '.pptx'
|
|
745
|
+
document.body.appendChild(a)
|
|
746
|
+
a.click()
|
|
747
|
+
a.remove()
|
|
748
|
+
setTimeout(function () { URL.revokeObjectURL(url) }, 1000)
|
|
749
|
+
setStatus('idle', order)
|
|
750
|
+
})
|
|
751
|
+
.catch(function (error) {
|
|
752
|
+
// A rejection carrying no verdict is not the route speaking: the
|
|
753
|
+
// request never landed, or it landed and reading the body failed
|
|
754
|
+
// halfway. Neither says anything final about the deck. Only the
|
|
755
|
+
// route's own answers do — and `refused` counts among them, since
|
|
756
|
+
// clicking again sends the identical request to the same closed
|
|
757
|
+
// door. Folding 401/403 into `failed` offered a retry that could
|
|
758
|
+
// only fail again, under a sentence about the network.
|
|
759
|
+
var verdict = error && error.verdict
|
|
760
|
+
setStatus(isFinal(verdict) ? verdict : 'failed', order)
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return h(
|
|
765
|
+
'button',
|
|
766
|
+
{
|
|
767
|
+
onClick: save,
|
|
768
|
+
className: props.className,
|
|
769
|
+
style: props.style,
|
|
770
|
+
title: isFinal(status)
|
|
771
|
+
? hintFor(status)
|
|
772
|
+
: status === 'failed'
|
|
773
|
+
? UNREACHABLE_HINT
|
|
774
|
+
: props.draft
|
|
775
|
+
? 'Download the editable .pptx. It is a draft: some pages are still unfilled placeholders'
|
|
776
|
+
: 'Download the editable .pptx',
|
|
777
|
+
},
|
|
778
|
+
status === 'busy'
|
|
779
|
+
? 'Saving…'
|
|
780
|
+
: isFinal(status)
|
|
781
|
+
? exportLabelFor(status)
|
|
782
|
+
: status === 'failed'
|
|
783
|
+
? 'Retry download'
|
|
784
|
+
: 'Download .pptx',
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The missing card's own line, under the header.
|
|
790
|
+
*
|
|
791
|
+
* Said out loud rather than left to a tooltip: the whole complaint this
|
|
792
|
+
* exists to answer is a card that told the user nothing at all.
|
|
793
|
+
*/
|
|
794
|
+
function VerdictNote(props) {
|
|
795
|
+
return h(
|
|
796
|
+
'div',
|
|
797
|
+
{ style: { fontSize: 12, color: COLORS.dim, paddingTop: 2 } },
|
|
798
|
+
hintFor(props.verdict),
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The badge that marks a deck the route will not serve, and says which
|
|
804
|
+
* kind of "will not".
|
|
805
|
+
*
|
|
806
|
+
* It used to read `gone` whatever had happened, which is how a damaged
|
|
807
|
+
* `record.json` reached the user as a deletion. The word comes from the
|
|
808
|
+
* verdict now, and the verdict comes from a code the route sends.
|
|
809
|
+
*/
|
|
810
|
+
function VerdictBadge(props) {
|
|
811
|
+
return h(
|
|
812
|
+
'span',
|
|
813
|
+
{
|
|
814
|
+
title: hintFor(props.verdict),
|
|
815
|
+
style: {
|
|
816
|
+
border: '1px solid ' + COLORS.line,
|
|
817
|
+
borderRadius: 5,
|
|
818
|
+
padding: '0 6px',
|
|
819
|
+
fontWeight: 600,
|
|
820
|
+
textTransform: 'uppercase',
|
|
821
|
+
fontSize: 10,
|
|
822
|
+
letterSpacing: '0.04em',
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
badgeFor(props.verdict),
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// THE STORE.
|
|
830
|
+
//
|
|
831
|
+
// Seven review rounds produced seven defects of one shape: state that
|
|
832
|
+
// could not say which preview it was about, or a write that landed on the
|
|
833
|
+
// wrong one. Three of those were introduced by the fix for the previous
|
|
834
|
+
// one. What is left is one entry and one number, and this note describes
|
|
835
|
+
// only what the code in front of you does.
|
|
836
|
+
//
|
|
837
|
+
// ONE ENTRY. The card draws one deck at a time, so the store holds one
|
|
838
|
+
// deck's state, stamped with the preview id it belongs to. A render reads
|
|
839
|
+
// it only while that stamp matches the deck it is drawing (`mine`);
|
|
840
|
+
// otherwise it reads `UNASKED` and the deck that has scrolled past lends
|
|
841
|
+
// nothing to the one that replaced it — not its verdict, not its open
|
|
842
|
+
// viewer, not its download status. Arriving at a deck replaces the entry
|
|
843
|
+
// outright rather than adjusting the one already there, which is what
|
|
844
|
+
// makes that true of every field at once instead of field by field.
|
|
845
|
+
//
|
|
846
|
+
// ONE NUMBER. A monotonic counter hands out a ticket per request, and one
|
|
847
|
+
// per arrival. An entry starts life holding its arrival's ticket as the
|
|
848
|
+
// high-water mark of both lanes, and a write is accepted only if its
|
|
849
|
+
// ticket is at least the mark of the lane it names. That single
|
|
850
|
+
// comparison does two jobs:
|
|
851
|
+
//
|
|
852
|
+
// - within one stay, an older answer cannot overwrite a newer one. Two
|
|
853
|
+
// clicks on Open used to race last-writer-wins: the second request
|
|
854
|
+
// answered 410 and settled the card, then the first answered 200 and
|
|
855
|
+
// un-settled it, reopening a viewer for a deck the route had just
|
|
856
|
+
// disowned. "Last to arrive" and "most recent" are not the same thing,
|
|
857
|
+
// and only one of them is a fact about the deck.
|
|
858
|
+
//
|
|
859
|
+
// - across stays, an answer from a stay the card has left is refused by
|
|
860
|
+
// the same line, because the current entry's mark was taken when the
|
|
861
|
+
// card arrived — which is after that request went out. This is why
|
|
862
|
+
// there is no second stamp to compare. An earlier version addressed
|
|
863
|
+
// entries by `{ id, visit }` and matched the visit exactly, and it had
|
|
864
|
+
// to: a new entry started both lanes at zero, so a departed stay's
|
|
865
|
+
// ticket was the higher number and won. Seeding the marks from the
|
|
866
|
+
// arrival inverts that. The danger did not go away, the arithmetic
|
|
867
|
+
// turned round — leave the seed at zero and a viewer opens by itself
|
|
868
|
+
// over a deck the user has only just come back to.
|
|
869
|
+
//
|
|
870
|
+
// Two lanes, deck and download, each with a mark of its own, so a slow
|
|
871
|
+
// export cannot make a fresh verdict look stale, or the reverse.
|
|
872
|
+
//
|
|
873
|
+
// Nothing is written during a render — not the store, and not the counter
|
|
874
|
+
// below. The store is *read* during one, in `deck`, which is ordinary
|
|
875
|
+
// React. What this file must not do is let a render decide or remember
|
|
876
|
+
// anything, which is how the ref-based version broke under a render that
|
|
877
|
+
// was thrown away. Nothing is captured from a render either: a request
|
|
878
|
+
// takes its ticket when it is sent, and what it is measured against is
|
|
879
|
+
// whatever the store holds when it answers.
|
|
880
|
+
//
|
|
881
|
+
// `useLayoutEffect` rather than `useEffect` for the maintenance, and the
|
|
882
|
+
// reason is narrow: it puts the entry in place during the commit that
|
|
883
|
+
// paints the buttons, so there is no frame in which a click could land on
|
|
884
|
+
// a deck that has nowhere to write its answer. It is not load-bearing for
|
|
885
|
+
// ordering — the fetch effect waits for the entry through its dependency
|
|
886
|
+
// list rather than through hook order, so a passive spelling declared
|
|
887
|
+
// anywhere fetches just the same. That was measured, not reasoned: with
|
|
888
|
+
// `maintainStore` rewritten as a passive effect and moved below the fetch
|
|
889
|
+
// effect, all 189 tests across this plugin's two suites stay green. A
|
|
890
|
+
// structural test could tell the two spellings apart. No behaviour test
|
|
891
|
+
// in this suite does.
|
|
892
|
+
//
|
|
893
|
+
// What this does NOT do, stated because overclaiming here has itself been
|
|
894
|
+
// a recurring defect:
|
|
895
|
+
//
|
|
896
|
+
// - it does not stop a request that has been left behind from finishing.
|
|
897
|
+
// A download started on a previous visit still saves its file, because
|
|
898
|
+
// the user did ask for it; what it cannot do is report back.
|
|
899
|
+
// - it cannot stop a component from keeping state of its own and going
|
|
900
|
+
// around the store. See the tripwire test for what is and is not
|
|
901
|
+
// caught, and `.issues/…/design.md` §5.12 for the list of shapes that
|
|
902
|
+
// are known to slip past it.
|
|
903
|
+
// - the id stamp is not something a behaviour test in this suite can
|
|
904
|
+
// see, and that half was measured: drop it from the read and all 189
|
|
905
|
+
// tests stay green (round 9, K1). `maintainStore` is a layout effect,
|
|
906
|
+
// so the render that pairs the old entry with the new id is replaced
|
|
907
|
+
// before the browser paints and before any assertion runs. It is kept
|
|
908
|
+
// for what that render would do on the way past — mount the viewer's
|
|
909
|
+
// iframe against the new deck's URL on the strength of the old deck's
|
|
910
|
+
// open flag, and send the bundle request while the old entry still
|
|
911
|
+
// holds the marks it would be measured against. That half is read off
|
|
912
|
+
// React's commit order, not off a failing test, and it is free: the
|
|
913
|
+
// same comparison is already there for the fetch effect to wait on.
|
|
914
|
+
//
|
|
915
|
+
// `AbortController` was considered and left out: cancelling still leaves
|
|
916
|
+
// an abort rejection to handle, so it moves a check rather than removing
|
|
917
|
+
// one, and adds a second thing to keep in sync.
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* What a preview is, to this card, before anything has been asked about
|
|
921
|
+
* it — and what a render reads for a deck whose entry is not in place
|
|
922
|
+
* yet. `id` is null because that is precisely what makes it nobody's.
|
|
923
|
+
*/
|
|
924
|
+
var UNASKED = {
|
|
925
|
+
id: null,
|
|
926
|
+
bundle: null,
|
|
927
|
+
verdict: null,
|
|
928
|
+
viewerOpen: false,
|
|
929
|
+
viewerPage: 1,
|
|
930
|
+
download: 'idle',
|
|
931
|
+
attempt: 0,
|
|
932
|
+
seen: { deck: 0, download: 0 },
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* A brand new entry for a deck this card has just arrived at.
|
|
937
|
+
*
|
|
938
|
+
* Both lanes start at the arrival's own ticket, which is the whole of how
|
|
939
|
+
* a request from an earlier stay is turned away: it took a smaller number
|
|
940
|
+
* before this arrival took its own.
|
|
941
|
+
*/
|
|
942
|
+
function arrivalAt(id, ticket) {
|
|
943
|
+
return Object.assign({}, UNASKED, { id: id, seen: { deck: ticket, download: ticket } })
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* The card's monotonic counter: one number per request, and one per
|
|
948
|
+
* arrival.
|
|
949
|
+
*
|
|
950
|
+
* It lives in this factory's closure, so every card the plugin renders
|
|
951
|
+
* draws from the same sequence — which is more than the store needs (each
|
|
952
|
+
* card's numbers are only ever compared with its own) and keeps it out of
|
|
953
|
+
* component state, and therefore out of the render.
|
|
954
|
+
*
|
|
955
|
+
* Arrivals draw from the same sequence as requests on purpose, and it is
|
|
956
|
+
* not a convenience: an arrival's number has to be comparable with the
|
|
957
|
+
* tickets of requests already in flight, or it could not outrank them.
|
|
958
|
+
*/
|
|
959
|
+
var lastTicket = 0
|
|
960
|
+
function nextTicket() {
|
|
961
|
+
lastTicket += 1
|
|
962
|
+
return lastTicket
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The card itself: a strip of thumbnails, and a button that opens the viewer. */
|
|
966
|
+
return function PptwisePreviewCard(props) {
|
|
967
|
+
var direct = bundleOf(props.block)
|
|
968
|
+
var previewId = previewIdOf(props.block)
|
|
969
|
+
var pageCount = pageCountOf(props.block)
|
|
970
|
+
|
|
971
|
+
// One entry, stamped with the deck it belongs to — see THE STORE.
|
|
972
|
+
var store = useState(UNASKED)
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Keep the entry pointed at the deck this card is showing.
|
|
976
|
+
*
|
|
977
|
+
* The only place an entry is replaced. Runs on commit, so a render
|
|
978
|
+
* React threw away changes nothing.
|
|
979
|
+
*
|
|
980
|
+
* Arriving at a deck builds a NEW entry, always, even for an id this
|
|
981
|
+
* card was showing a moment ago. That is the point: the previous stay
|
|
982
|
+
* and this one are two different recipients, so the requests still in
|
|
983
|
+
* flight from the previous one have a number to be turned away by (see
|
|
984
|
+
* `arrivalAt`). Nothing is carried over — carrying the download status
|
|
985
|
+
* alone was enough, once, to label a live deck's export `Unavailable`
|
|
986
|
+
* on the strength of the previous deck's 410.
|
|
987
|
+
*
|
|
988
|
+
* The store is left strictly alone when it already holds this deck, so
|
|
989
|
+
* a second run of this effect cannot replace a live entry. The number
|
|
990
|
+
* is taken outside the updater because React may run an updater more
|
|
991
|
+
* than once, and this one has to stay a plain function of `entry`.
|
|
992
|
+
*/
|
|
993
|
+
function maintainStore() {
|
|
994
|
+
var arrival = nextTicket()
|
|
995
|
+
store[1](function (entry) {
|
|
996
|
+
return entry.id === previewId ? entry : arrivalAt(previewId, arrival)
|
|
997
|
+
})
|
|
998
|
+
}
|
|
999
|
+
react.useLayoutEffect(maintainStore, [previewId])
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* The only way an asynchronous result changes anything.
|
|
1003
|
+
*
|
|
1004
|
+
* `order` is `{ lane, ticket }`: which sequence this write belongs to,
|
|
1005
|
+
* and where in it. UI actions that are their own newest intent take a
|
|
1006
|
+
* fresh ticket, and the two lanes are independent.
|
|
1007
|
+
*
|
|
1008
|
+
* One refusal, doing two jobs — see THE STORE. An older ticket never
|
|
1009
|
+
* overwrites a newer one in the same lane, and an answer from a stay
|
|
1010
|
+
* this card has left is older than the arrival that replaced it.
|
|
1011
|
+
*/
|
|
1012
|
+
function commit(change, order) {
|
|
1013
|
+
store[1](function (entry) {
|
|
1014
|
+
if (order.ticket < entry.seen[order.lane]) return entry
|
|
1015
|
+
var seen = Object.assign({}, entry.seen)
|
|
1016
|
+
seen[order.lane] = order.ticket
|
|
1017
|
+
return Object.assign({}, entry, change, { seen: seen })
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Read side: this preview's own entry, or the state of a preview
|
|
1022
|
+
// nobody has asked about yet. Derived from props, so a render decides
|
|
1023
|
+
// nothing and remembers nothing. `mine` is also what the fetch effect
|
|
1024
|
+
// waits on: until the entry is this deck's, a request sent from here
|
|
1025
|
+
// would be measured against the previous deck's marks.
|
|
1026
|
+
var mine = previewId !== null && store[0].id === previewId
|
|
1027
|
+
var deck = mine ? store[0] : UNASKED
|
|
1028
|
+
var verdict = deck.verdict
|
|
1029
|
+
// Every way this deck will not open, under one name. `gone` used to
|
|
1030
|
+
// stand in for all of them, which is why a damaged preview and a
|
|
1031
|
+
// refused request both told the user their deck had been deleted.
|
|
1032
|
+
var settled = isFinal(verdict)
|
|
1033
|
+
|
|
1034
|
+
// Structured payload when the runtime computed one (native-mode,
|
|
1035
|
+
// top-level call); otherwise fetch it by id from the plugin's own
|
|
1036
|
+
// route, which is what Code Mode's sub-calls need.
|
|
1037
|
+
useEffect(
|
|
1038
|
+
function () {
|
|
1039
|
+
// `!mine` is the render between arriving at a deck and
|
|
1040
|
+
// `maintainStore` putting its entry in place. A request sent from
|
|
1041
|
+
// there would be ordered against the *previous* deck's marks, and
|
|
1042
|
+
// the entry it wrote into would be thrown away a moment later — so
|
|
1043
|
+
// it waits, and `mine` is in the dependency list below, which is
|
|
1044
|
+
// what brings it back the moment there is somewhere to write to.
|
|
1045
|
+
if (direct || !mine || deck.bundle) return
|
|
1046
|
+
var order = { lane: 'deck', ticket: nextTicket() }
|
|
1047
|
+
fetch(previewBundleUrl(previewId))
|
|
1048
|
+
.then(function (r) {
|
|
1049
|
+
// A 404 or 410 is an answer, not a failure to get one: the
|
|
1050
|
+
// harness is there and it has never heard of this deck, or
|
|
1051
|
+
// has and cannot serve what is left of it. Swallowing that is
|
|
1052
|
+
// what made a whole card — thumbnails, Open, Download —
|
|
1053
|
+
// disappear out of a reopened transcript without a word.
|
|
1054
|
+
// Anything else this records as `unreachable`, which the card
|
|
1055
|
+
// reads as "no verdict yet" rather than as a dead deck.
|
|
1056
|
+
return verdictOf(r).then(function (answer) {
|
|
1057
|
+
if (answer !== 'alive') {
|
|
1058
|
+
commit({ verdict: answer }, order)
|
|
1059
|
+
return null
|
|
1060
|
+
}
|
|
1061
|
+
return r.json()
|
|
1062
|
+
})
|
|
1063
|
+
})
|
|
1064
|
+
.then(function (b) {
|
|
1065
|
+
if (!b) return
|
|
1066
|
+
commit({ bundle: b, verdict: 'alive' }, order)
|
|
1067
|
+
})
|
|
1068
|
+
.catch(function () {
|
|
1069
|
+
// Not a verdict: the request never landed, or it landed and the
|
|
1070
|
+
// body would not parse. Either way that is a fact about this
|
|
1071
|
+
// attempt and not about the deck, and reporting it as a missing
|
|
1072
|
+
// preview would outlive its cause. The generic row is the
|
|
1073
|
+
// degrade, and every later click — Open, Download — asks the
|
|
1074
|
+
// route again from scratch.
|
|
1075
|
+
commit({ verdict: UNREACHABLE_VERDICT }, order)
|
|
1076
|
+
})
|
|
1077
|
+
},
|
|
1078
|
+
[direct, previewId, mine, deck.bundle, deck.attempt],
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
var bundle = direct || deck.bundle
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Ask before opening, every time.
|
|
1085
|
+
*
|
|
1086
|
+
* The bundle a card is drawing came from one request, and the viewer
|
|
1087
|
+
* is a second one made minutes or days later — a browser tab left open
|
|
1088
|
+
* across a harness restart is enough to put a whole deck's lifetime
|
|
1089
|
+
* between them. Opening on the strength of the first request is how
|
|
1090
|
+
* the viewer landed on `{"error":"unknown preview id"}` rendered as a
|
|
1091
|
+
* document, framed by this card's own Close button. The check is one
|
|
1092
|
+
* loopback request against the route the iframe is about to hit
|
|
1093
|
+
* anyway, and it doubles as a refresh of the strip.
|
|
1094
|
+
*
|
|
1095
|
+
* The ticket is taken here, when the click happens, and it is the only
|
|
1096
|
+
* thing this request carries — see THE STORE. No write asks "is this
|
|
1097
|
+
* still current": arriving anywhere else, including back at this same
|
|
1098
|
+
* deck, takes a higher number, so an answer to a click the user walked
|
|
1099
|
+
* away from lands nowhere. Both halves of that have been a defect here.
|
|
1100
|
+
* First the wrong deck: click Open on A, let the card move to B while
|
|
1101
|
+
* A's request is in flight, and A's success opened the viewer — pointed
|
|
1102
|
+
* at B, which had been asked nothing at all. Then the wrong time: go
|
|
1103
|
+
* A → B → A, and A's first-stay success opened a viewer over the deck
|
|
1104
|
+
* the user had only just come back to.
|
|
1105
|
+
*/
|
|
1106
|
+
function openViewer(page) {
|
|
1107
|
+
if (!previewId || settled) return
|
|
1108
|
+
var startAt = page > 1 ? page : 1
|
|
1109
|
+
// Two clicks on Open are two requests, and the one that answers
|
|
1110
|
+
// second is not necessarily the newer one. The ticket is what stops
|
|
1111
|
+
// an older 200 from undoing a newer 410 and reopening the viewer on
|
|
1112
|
+
// a deck the route has just disowned.
|
|
1113
|
+
var order = { lane: 'deck', ticket: nextTicket() }
|
|
1114
|
+
fetch(previewBundleUrl(previewId))
|
|
1115
|
+
.then(function (r) {
|
|
1116
|
+
return verdictOf(r).then(function (answer) {
|
|
1117
|
+
if (answer !== 'alive') {
|
|
1118
|
+
// Recorded, and therefore said out loud: this is the click
|
|
1119
|
+
// that used to do nothing visible at all when the route
|
|
1120
|
+
// refused it, leaving the button in place and the user
|
|
1121
|
+
// clicking it again. Every verdict now reaches the card.
|
|
1122
|
+
commit({ verdict: answer }, order)
|
|
1123
|
+
return null
|
|
1124
|
+
}
|
|
1125
|
+
return r.json()
|
|
1126
|
+
})
|
|
1127
|
+
})
|
|
1128
|
+
.then(function (b) {
|
|
1129
|
+
if (!b) return
|
|
1130
|
+
commit({ bundle: b, verdict: 'alive', viewerOpen: true, viewerPage: startAt }, order)
|
|
1131
|
+
})
|
|
1132
|
+
.catch(function () {
|
|
1133
|
+
// The modal stays shut either way, but only the route's own
|
|
1134
|
+
// verdict retires the deck. A click that never reached the
|
|
1135
|
+
// harness, or reached it and came back unreadable, leaves the
|
|
1136
|
+
// strip live and the button clickable, which is what makes trying
|
|
1137
|
+
// again the obvious next move rather than an impossible one.
|
|
1138
|
+
commit({ verdict: UNREACHABLE_VERDICT }, order)
|
|
1139
|
+
})
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (!bundle) {
|
|
1143
|
+
// Nothing to draw and nothing said about why: the card refusing to
|
|
1144
|
+
// invent a deck it does not have.
|
|
1145
|
+
if (verdict === null || verdict === 'alive') return null
|
|
1146
|
+
// Otherwise the tool did run, and the transcript still says how long
|
|
1147
|
+
// the deck was. That number survives a deleted preview on purpose:
|
|
1148
|
+
// the host half keeps no gravestone on disk, so the tool's own
|
|
1149
|
+
// summary line, still sitting in the log, is the only evidence this
|
|
1150
|
+
// card can still read that nine pages once existed here. A card that vanishes instead is the one thing worse
|
|
1151
|
+
// than a card reporting a problem: the user is left doubting the deck
|
|
1152
|
+
// was ever generated.
|
|
1153
|
+
//
|
|
1154
|
+
// The Retry button appears only for the one verdict a retry can
|
|
1155
|
+
// change. `refused` and the final verdicts get the same row without
|
|
1156
|
+
// it, because a button that re-sends an identical request to a door
|
|
1157
|
+
// that just closed is a promise the card cannot keep.
|
|
1158
|
+
return h(
|
|
1159
|
+
'div',
|
|
1160
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4, padding: '2px 0 6px' } },
|
|
1161
|
+
h(
|
|
1162
|
+
'div',
|
|
1163
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: COLORS.dim } },
|
|
1164
|
+
h('span', { style: { color: COLORS.text, fontWeight: 600 } }, 'deck'),
|
|
1165
|
+
settled ? h(VerdictBadge, { verdict: verdict }) : null,
|
|
1166
|
+
pageCount !== null ? h('span', null, pageCount + ' pages') : null,
|
|
1167
|
+
isRetryable(verdict)
|
|
1168
|
+
? h(
|
|
1169
|
+
'button',
|
|
1170
|
+
{
|
|
1171
|
+
onClick: function () {
|
|
1172
|
+
commit({ attempt: deck.attempt + 1 }, { lane: 'deck', ticket: nextTicket() })
|
|
1173
|
+
},
|
|
1174
|
+
title: hintFor(verdict),
|
|
1175
|
+
style: {
|
|
1176
|
+
marginLeft: 'auto',
|
|
1177
|
+
font: 'inherit',
|
|
1178
|
+
fontSize: 12,
|
|
1179
|
+
padding: '3px 10px',
|
|
1180
|
+
borderRadius: 7,
|
|
1181
|
+
border: '1px solid ' + COLORS.line,
|
|
1182
|
+
background: 'transparent',
|
|
1183
|
+
color: COLORS.text,
|
|
1184
|
+
cursor: 'pointer',
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
'Retry',
|
|
1188
|
+
)
|
|
1189
|
+
: null,
|
|
1190
|
+
),
|
|
1191
|
+
h(VerdictNote, { verdict: verdict }),
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
var pages = viewablePages(bundle)
|
|
1196
|
+
if (pages.length === 0) return null
|
|
1197
|
+
|
|
1198
|
+
var slide = bundle.slide || { width: 1280, height: 720 }
|
|
1199
|
+
var findingCount = bundle.pages.reduce(function (n, p) {
|
|
1200
|
+
return n + ((p.findings && p.findings.length) || 0)
|
|
1201
|
+
}, 0)
|
|
1202
|
+
|
|
1203
|
+
return h(
|
|
1204
|
+
'div',
|
|
1205
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '2px 0 6px' } },
|
|
1206
|
+
h(
|
|
1207
|
+
'div',
|
|
1208
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: COLORS.dim } },
|
|
1209
|
+
h('span', { style: { color: COLORS.text, fontWeight: 600 } }, bundle.title || 'deck'),
|
|
1210
|
+
// The deck is on screen and the route has already said it cannot
|
|
1211
|
+
// serve it any more. Both halves are true, and the thumbnails are
|
|
1212
|
+
// the half worth keeping — they are the deck, not a picture of a
|
|
1213
|
+
// button. What goes is the promise that clicking them leads
|
|
1214
|
+
// anywhere.
|
|
1215
|
+
settled ? h(VerdictBadge, { verdict: verdict }) : null,
|
|
1216
|
+
// The deck still has placeholder pages. The export goes out
|
|
1217
|
+
// anyway (see `--draft` in preview-tool.js's `execute`), so this
|
|
1218
|
+
// badge is what keeps that from being a silent substitution: the
|
|
1219
|
+
// user is looking at unfinished work and the file they save says
|
|
1220
|
+
// so too.
|
|
1221
|
+
bundle.draft
|
|
1222
|
+
? h(
|
|
1223
|
+
'span',
|
|
1224
|
+
{
|
|
1225
|
+
title: 'Some pages are still unfilled placeholders — the export is labelled a draft',
|
|
1226
|
+
style: {
|
|
1227
|
+
border: '1px solid ' + COLORS.line,
|
|
1228
|
+
borderRadius: 5,
|
|
1229
|
+
padding: '0 6px',
|
|
1230
|
+
fontWeight: 600,
|
|
1231
|
+
textTransform: 'uppercase',
|
|
1232
|
+
fontSize: 10,
|
|
1233
|
+
letterSpacing: '0.04em',
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
'draft',
|
|
1237
|
+
)
|
|
1238
|
+
: null,
|
|
1239
|
+
h('span', null, bundle.pages.length + ' pages'),
|
|
1240
|
+
findingCount > 0 ? h('span', null, findingCount + ' audit findings') : null,
|
|
1241
|
+
// No viewer without an id, and none for an id the route has
|
|
1242
|
+
// already disowned: the full-size view is a route keyed by it. A
|
|
1243
|
+
// button that opened an empty frame would be worse than an absent
|
|
1244
|
+
// one, and the thumbnails below go inert for the same reason.
|
|
1245
|
+
previewId && !settled
|
|
1246
|
+
? h(
|
|
1247
|
+
'button',
|
|
1248
|
+
{
|
|
1249
|
+
onClick: function () { openViewer(1) },
|
|
1250
|
+
style: {
|
|
1251
|
+
marginLeft: 'auto',
|
|
1252
|
+
font: 'inherit',
|
|
1253
|
+
fontSize: 12,
|
|
1254
|
+
padding: '3px 10px',
|
|
1255
|
+
borderRadius: 7,
|
|
1256
|
+
border: '1px solid ' + COLORS.line,
|
|
1257
|
+
background: 'transparent',
|
|
1258
|
+
color: COLORS.text,
|
|
1259
|
+
cursor: 'pointer',
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
'Open',
|
|
1263
|
+
)
|
|
1264
|
+
: null,
|
|
1265
|
+
previewId
|
|
1266
|
+
? h(ExportButton, {
|
|
1267
|
+
previewId: previewId,
|
|
1268
|
+
name: bundle.title,
|
|
1269
|
+
draft: bundle.draft,
|
|
1270
|
+
verdict: verdict,
|
|
1271
|
+
status: deck.download,
|
|
1272
|
+
onStatus: function (next, order) { commit({ download: next }, order) },
|
|
1273
|
+
style: {
|
|
1274
|
+
font: 'inherit',
|
|
1275
|
+
fontSize: 12,
|
|
1276
|
+
padding: '3px 10px',
|
|
1277
|
+
borderRadius: 7,
|
|
1278
|
+
border: '1px solid ' + COLORS.line,
|
|
1279
|
+
background: 'transparent',
|
|
1280
|
+
color: COLORS.text,
|
|
1281
|
+
cursor: 'pointer',
|
|
1282
|
+
},
|
|
1283
|
+
})
|
|
1284
|
+
: null,
|
|
1285
|
+
),
|
|
1286
|
+
h(
|
|
1287
|
+
'div',
|
|
1288
|
+
{ style: { display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 2 } },
|
|
1289
|
+
stripPages(pages).map(function (page, i) {
|
|
1290
|
+
return h(
|
|
1291
|
+
'div',
|
|
1292
|
+
{ key: page.id || i, style: { flex: '0 0 auto', width: 132 } },
|
|
1293
|
+
h(Frame, {
|
|
1294
|
+
page: page,
|
|
1295
|
+
prefix: 'pft' + i + '-',
|
|
1296
|
+
width: slide.width,
|
|
1297
|
+
height: slide.height,
|
|
1298
|
+
title: (page.type || 'page') + ' ' + pageNumberOf(page, i),
|
|
1299
|
+
// Opens the viewer on this page. Clicking page 3 and
|
|
1300
|
+
// landing on page 1 is the kind of small lie that makes a
|
|
1301
|
+
// strip feel decorative.
|
|
1302
|
+
onClick:
|
|
1303
|
+
previewId && !settled
|
|
1304
|
+
? function () { openViewer(pageNumberOf(page, i)) }
|
|
1305
|
+
: undefined,
|
|
1306
|
+
}),
|
|
1307
|
+
)
|
|
1308
|
+
}),
|
|
1309
|
+
),
|
|
1310
|
+
settled ? h(VerdictNote, { verdict: verdict }) : null,
|
|
1311
|
+
// No id comparison here, and none needed: `deck` is this preview's own
|
|
1312
|
+
// entry or the unasked state, so a viewer left open on another deck
|
|
1313
|
+
// is not something this can read.
|
|
1314
|
+
deck.viewerOpen && previewId && !settled
|
|
1315
|
+
? h(Modal, {
|
|
1316
|
+
src: previewHtmlUrl(previewId, deck.viewerPage),
|
|
1317
|
+
previewId: previewId,
|
|
1318
|
+
name: bundle.title,
|
|
1319
|
+
draft: bundle.draft,
|
|
1320
|
+
downloadStatus: deck.download,
|
|
1321
|
+
onDownloadStatus: function (next, order) { commit({ download: next }, order) },
|
|
1322
|
+
onClose: function () {
|
|
1323
|
+
commit({ viewerOpen: false, viewerPage: 1 }, { lane: 'deck', ticket: nextTicket() })
|
|
1324
|
+
},
|
|
1325
|
+
})
|
|
1326
|
+
: null,
|
|
1327
|
+
)
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function registerCard(ctx) {
|
|
1332
|
+
// `slots` is a declared dependency (see `exports.inject` below), so
|
|
1333
|
+
// cordis does not apply this plugin until the service exists. The guard
|
|
1334
|
+
// stays as a belt-and-braces check, but it must never be the thing that
|
|
1335
|
+
// silently makes this card a no-op — which is exactly what happened
|
|
1336
|
+
// when `inject` was empty: apply ran before the slot registry was up,
|
|
1337
|
+
// this returned quietly, and the tool rendered in the generic row with
|
|
1338
|
+
// no sign anything had failed.
|
|
1339
|
+
if (!ctx.slots || typeof ctx.slots.inject !== 'function') {
|
|
1340
|
+
console.error('[pptwise] preview card skipped: no slot registry on this context')
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
var react
|
|
1344
|
+
try {
|
|
1345
|
+
react = require('react')
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
console.error('[pptwise] preview card skipped: ' + error)
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
1350
|
+
var Card = PreviewCard(react)
|
|
1351
|
+
ctx.slots.inject('tool.call.toolview', function* () {
|
|
1352
|
+
yield ctx.slots.register({ name: 'tool.call.toolview', key: TOOL_NAME }, Card)
|
|
1353
|
+
})
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function apply(ctx) {
|
|
1357
|
+
// A card that fails to register is a console line, not a dead turn —
|
|
1358
|
+
// the generic tool row still renders the call, same posture the host
|
|
1359
|
+
// half takes for its own registrations.
|
|
1360
|
+
try {
|
|
1361
|
+
registerCard(ctx)
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
console.error('[pptwise] preview card registration skipped: ' + error)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
exports.apply = apply
|
|
1368
|
+
// Declared, not sniffed: cordis holds this plugin until the slot registry
|
|
1369
|
+
// is up. The shipped `ui-skill` registrant declares the same service for
|
|
1370
|
+
// the same reason.
|
|
1371
|
+
exports.inject = ['slots']
|
|
1372
|
+
// Exposed for this repo's tests only; not part of the plugin contract.
|
|
1373
|
+
exports.__testing = {
|
|
1374
|
+
bundleOf: bundleOf,
|
|
1375
|
+
previewIdOf: previewIdOf,
|
|
1376
|
+
pageCountOf: pageCountOf,
|
|
1377
|
+
verdictOf: verdictOf,
|
|
1378
|
+
isRetryable: isRetryable,
|
|
1379
|
+
isFinal: isFinal,
|
|
1380
|
+
isZip: isZip,
|
|
1381
|
+
ROUTE_HEADER: ROUTE_HEADER,
|
|
1382
|
+
MISSING_HINT: MISSING_HINT,
|
|
1383
|
+
UNREACHABLE_HINT: UNREACHABLE_HINT,
|
|
1384
|
+
REFUSED_HINT: REFUSED_HINT,
|
|
1385
|
+
DAMAGED_HINT: DAMAGED_HINT,
|
|
1386
|
+
namespaceIds: namespaceIds,
|
|
1387
|
+
viewablePages: viewablePages,
|
|
1388
|
+
stripPages: stripPages,
|
|
1389
|
+
hasMarkup: hasMarkup,
|
|
1390
|
+
pageNumberOf: pageNumberOf,
|
|
1391
|
+
previewHtmlUrl: previewHtmlUrl,
|
|
1392
|
+
previewBundleUrl: previewBundleUrl,
|
|
1393
|
+
STRIP_PAGES: STRIP_PAGES,
|
|
1394
|
+
TOOL_NAME: TOOL_NAME,
|
|
1395
|
+
}
|
|
1396
|
+
return module.exports
|
|
1397
|
+
},
|
|
1398
|
+
})
|