@office-kit/pptx-dev 0.9.0 → 0.11.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/README.md +98 -18
- package/dist/animation-player.js +1 -0
- package/dist/build-cpmt-r9Q.mjs +131 -0
- package/dist/build-cpmt-r9Q.mjs.map +1 -0
- package/dist/cli.mjs +1918 -159
- package/dist/cli.mjs.map +1 -1
- package/dist/editor.css +1 -0
- package/dist/editor.js +162 -0
- package/dist/index.d.mts +36 -1
- package/dist/index.mjs +1 -1
- package/dist/media-player.js +1 -0
- package/dist/src-CI73YbYS.mjs +1741 -0
- package/dist/src-CI73YbYS.mjs.map +1 -0
- package/dist/terminal-client.js +18 -18
- package/dist/worker.mjs +1 -65
- package/dist/worker.mjs.map +1 -1
- package/package.json +7 -6
- package/dist/src-YH6kAXbA.mjs +0 -380
- package/dist/src-YH6kAXbA.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as createDeckBuilder, i as initProject, n as exportDeck, r as inspectTemplate } from "./src-
|
|
2
|
+
import { a as editorStore, c as createDeckBuilder, i as initProject, n as exportDeck, o as mergeDecks, r as inspectTemplate, s as sourceFingerprint } from "./src-CI73YbYS.mjs";
|
|
3
|
+
import { n as renderDeck } from "./build-cpmt-r9Q.mjs";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
|
-
import { chmod, lstat, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { watch } from "node:fs";
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
8
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { getGroupChildren, getShapeBounds, getShapeId, getShapeKind, getShapeRotation, getShapeXmlString, getSlideShapes, getSlideXmlString, getSlides, loadPresentation } from "@office-kit/pptx";
|
|
10
|
+
import { planPropEdit, planTextEdit, verifyTextEdit } from "@office-kit/pptx-dsl/source-edit";
|
|
9
11
|
import { chromium } from "playwright";
|
|
10
12
|
import { createServer } from "node:http";
|
|
11
13
|
import MarkdownIt from "markdown-it";
|
|
@@ -112,10 +114,21 @@ async function createHistory(directory, notify = () => {}) {
|
|
|
112
114
|
async function replace(change, expected, next) {
|
|
113
115
|
if (!equal(await contents(change.file), expected)) throw new Error(`History conflict in ${change.file}. Newer changes were preserved.`);
|
|
114
116
|
const file = join(root, change.file);
|
|
115
|
-
if (next)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if (!next) return unlink(file);
|
|
118
|
+
await mkdir(dirname(file), { recursive: true });
|
|
119
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
120
|
+
try {
|
|
121
|
+
await writeFile(temporary, next, { flag: "wx" });
|
|
122
|
+
const mode = await lstat(file).then((info) => info.mode, (cause) => {
|
|
123
|
+
if (cause.code === "ENOENT") return void 0;
|
|
124
|
+
throw cause;
|
|
125
|
+
});
|
|
126
|
+
if (mode !== void 0) await chmod(temporary, mode & 4095);
|
|
127
|
+
if (!equal(await contents(change.file), expected)) throw new Error(`History conflict in ${change.file}. Newer changes were preserved.`);
|
|
128
|
+
await rename(temporary, file);
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(temporary, { force: true });
|
|
131
|
+
}
|
|
119
132
|
}
|
|
120
133
|
try {
|
|
121
134
|
record(await snapshot(), "Initial state");
|
|
@@ -188,12 +201,19 @@ async function createHistory(directory, notify = () => {}) {
|
|
|
188
201
|
}
|
|
189
202
|
//#endregion
|
|
190
203
|
//#region src/text-edit.ts
|
|
204
|
+
const planErrors = {
|
|
205
|
+
invalid: "Invalid text edit.",
|
|
206
|
+
"not-unique-on-slide": "Text cannot be located uniquely on this slide. Use Apply with AI.",
|
|
207
|
+
"not-found": "Text is computed or has multiple source matches. Use Apply with AI.",
|
|
208
|
+
ambiguous: "Text is computed or has multiple source matches. Use Apply with AI."
|
|
209
|
+
};
|
|
210
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
191
211
|
/** Literal edits are deliberately conservative: computed and ambiguous text goes to the agent. */
|
|
192
212
|
function createTextEditor(entry, preview, verify) {
|
|
193
213
|
let busy = false;
|
|
194
214
|
async function restore(change) {
|
|
195
|
-
if (await realpath(change.
|
|
196
|
-
await writeFile(change.
|
|
215
|
+
if (await realpath(change.path) !== change.path || await readFile(change.path, "utf8") !== change.after) throw new Error("Source changed during editing. Your newer changes were preserved.");
|
|
216
|
+
await writeFile(change.path, change.before);
|
|
197
217
|
}
|
|
198
218
|
return { async edit(value) {
|
|
199
219
|
if (busy) throw new Error("A text edit is already running.");
|
|
@@ -201,51 +221,329 @@ function createTextEditor(entry, preview, verify) {
|
|
|
201
221
|
try {
|
|
202
222
|
const current = preview();
|
|
203
223
|
if (value.revision !== current.revision) throw new Error("Preview changed. Select the text again.");
|
|
204
|
-
if (!Number.isInteger(value.slide) || !current.slides[value.slide] || typeof value.before !== "string" ||
|
|
205
|
-
if (current.slideTexts[value.slide].split(value.before).length !== 2) throw new Error("Text cannot be located uniquely on this slide. Use Apply with AI.");
|
|
224
|
+
if (!Number.isInteger(value.slide) || !current.slides[value.slide] || typeof value.before !== "string" || typeof value.after !== "string") throw new Error(planErrors.invalid);
|
|
206
225
|
const root = await realpath(dirname(entry));
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
const plan = planTextEdit({
|
|
227
|
+
files: (await Promise.all([...new Set(current.dependencies)].filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map(async (file) => {
|
|
228
|
+
const path = await realpath(file), local = relative(root, path);
|
|
229
|
+
if (local.startsWith("..") || isAbsolute(local) || local.split(/[\\/]/).includes("node_modules")) return null;
|
|
230
|
+
return {
|
|
231
|
+
path,
|
|
232
|
+
source: await readFile(path, "utf8")
|
|
233
|
+
};
|
|
234
|
+
}))).filter((file) => file !== null),
|
|
235
|
+
slideText: current.slideTexts[value.slide],
|
|
236
|
+
before: value.before,
|
|
237
|
+
after: value.after
|
|
238
|
+
});
|
|
239
|
+
if (!plan.ok) throw new Error(planErrors[plan.reason]);
|
|
240
|
+
const { change } = plan;
|
|
241
|
+
if (preview().revision !== current.revision || await realpath(change.path) !== change.path || await readFile(change.path, "utf8") !== change.before) throw new Error("Source changed. Select the text again.");
|
|
242
|
+
await writeFile(change.path, change.after);
|
|
243
|
+
try {
|
|
244
|
+
if (await verify()) throw new Error("The text change did not build.");
|
|
245
|
+
if (!verifyTextEdit({
|
|
246
|
+
previous: current,
|
|
247
|
+
next: preview(),
|
|
248
|
+
slide: value.slide,
|
|
249
|
+
before: value.before,
|
|
250
|
+
after: value.after
|
|
251
|
+
}).ok) throw new Error("This source affects other text or slides. Use Apply with AI for a local edit.");
|
|
252
|
+
if (await realpath(change.path) !== change.path || await readFile(change.path, "utf8") !== change.after) throw new Error("Source changed during editing.");
|
|
253
|
+
} catch (error) {
|
|
254
|
+
try {
|
|
255
|
+
await restore(change);
|
|
256
|
+
await verify();
|
|
257
|
+
} catch (cleanup) {
|
|
258
|
+
throw new Error(`${message(error)} ${message(cleanup)}`, { cause: error });
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
busy = false;
|
|
264
|
+
}
|
|
265
|
+
} };
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/deck-diff.ts
|
|
269
|
+
const ANGLE_UNITS = 6e4;
|
|
270
|
+
function shapesById(shapes) {
|
|
271
|
+
const result = /* @__PURE__ */ new Map();
|
|
272
|
+
const visit = (shape, grouped) => {
|
|
273
|
+
result.set(getShapeId(shape), {
|
|
274
|
+
shape,
|
|
275
|
+
grouped
|
|
276
|
+
});
|
|
277
|
+
for (const child of getGroupChildren(shape)) visit(child, true);
|
|
278
|
+
};
|
|
279
|
+
for (const shape of shapes) visit(shape, false);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
function withoutTransform(xml) {
|
|
283
|
+
return xml.replace(/(<a:xfrm\b[^>]*?) rot="-?\d+"/, "$1").replace(/<a:off x="-?\d+" y="-?\d+"\/>/, "").replace(/<a:ext cx="\d+" cy="\d+"\/>/, "");
|
|
284
|
+
}
|
|
285
|
+
function withoutShapes(xml) {
|
|
286
|
+
return xml.replace(/<p:spTree>[\s\S]*<\/p:spTree>/, "");
|
|
287
|
+
}
|
|
288
|
+
function geometryEdits(source, edited) {
|
|
289
|
+
const before = getShapeBounds(source);
|
|
290
|
+
const after = getShapeBounds(edited);
|
|
291
|
+
const edits = [];
|
|
292
|
+
if (before && after) {
|
|
293
|
+
const pairs = [
|
|
294
|
+
[
|
|
295
|
+
"x",
|
|
296
|
+
before.x,
|
|
297
|
+
after.x
|
|
298
|
+
],
|
|
299
|
+
[
|
|
300
|
+
"y",
|
|
301
|
+
before.y,
|
|
302
|
+
after.y
|
|
303
|
+
],
|
|
304
|
+
[
|
|
305
|
+
"width",
|
|
306
|
+
before.w,
|
|
307
|
+
after.w
|
|
308
|
+
],
|
|
309
|
+
[
|
|
310
|
+
"height",
|
|
311
|
+
before.h,
|
|
312
|
+
after.h
|
|
313
|
+
]
|
|
314
|
+
];
|
|
315
|
+
for (const [prop, from, to] of pairs) if (from !== to) edits.push({
|
|
316
|
+
prop,
|
|
317
|
+
from,
|
|
318
|
+
to
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const fromAngle = Math.round(getShapeRotation(source) * ANGLE_UNITS);
|
|
322
|
+
const toAngle = Math.round(getShapeRotation(edited) * ANGLE_UNITS);
|
|
323
|
+
if (fromAngle !== toAngle) edits.push({
|
|
324
|
+
prop: "rotation",
|
|
325
|
+
from: fromAngle,
|
|
326
|
+
to: toAngle
|
|
327
|
+
});
|
|
328
|
+
return edits;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* What the editor changed relative to the deck built from source. Shapes are
|
|
332
|
+
* matched by slide index and shape id — the editor edits the deck the server
|
|
333
|
+
* built, so ids agree. A change this diff cannot state as typed props is
|
|
334
|
+
* reported as `unsupported`, never dropped.
|
|
335
|
+
*/
|
|
336
|
+
function diffDecks(source, edited) {
|
|
337
|
+
const changes = [];
|
|
338
|
+
const sourceSlides = getSlides(source);
|
|
339
|
+
const editedSlides = getSlides(edited);
|
|
340
|
+
if (sourceSlides.length !== editedSlides.length) changes.push({
|
|
341
|
+
kind: "unsupported",
|
|
342
|
+
slide: null,
|
|
343
|
+
shapeId: null,
|
|
344
|
+
description: `slide count ${sourceSlides.length} → ${editedSlides.length}`
|
|
345
|
+
});
|
|
346
|
+
const count = Math.min(sourceSlides.length, editedSlides.length);
|
|
347
|
+
for (let slide = 0; slide < count; slide++) {
|
|
348
|
+
const sourceSlide = sourceSlides[slide];
|
|
349
|
+
const editedSlide = editedSlides[slide];
|
|
350
|
+
if (withoutShapes(getSlideXmlString(sourceSlide)) !== withoutShapes(getSlideXmlString(editedSlide))) changes.push({
|
|
351
|
+
kind: "unsupported",
|
|
352
|
+
slide,
|
|
353
|
+
shapeId: null,
|
|
354
|
+
description: "slide properties"
|
|
355
|
+
});
|
|
356
|
+
const before = shapesById(getSlideShapes(sourceSlide));
|
|
357
|
+
const after = shapesById(getSlideShapes(editedSlide));
|
|
358
|
+
for (const [shapeId, { shape, grouped }] of after) {
|
|
359
|
+
const original = before.get(shapeId)?.shape;
|
|
360
|
+
if (!original) {
|
|
361
|
+
changes.push({
|
|
362
|
+
kind: "unsupported",
|
|
363
|
+
slide,
|
|
364
|
+
shapeId,
|
|
365
|
+
description: "shape added"
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (getShapeKind(shape) === "group") {
|
|
370
|
+
if (geometryEdits(original, shape).length) changes.push({
|
|
371
|
+
kind: "unsupported",
|
|
372
|
+
slide,
|
|
373
|
+
shapeId,
|
|
374
|
+
description: "group transform"
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (withoutTransform(getShapeXmlString(original)) !== withoutTransform(getShapeXmlString(shape))) changes.push({
|
|
379
|
+
kind: "unsupported",
|
|
380
|
+
slide,
|
|
381
|
+
shapeId,
|
|
382
|
+
description: "properties other than position, size and rotation"
|
|
383
|
+
});
|
|
384
|
+
const edits = geometryEdits(original, shape);
|
|
385
|
+
if (edits.length && grouped) changes.push({
|
|
386
|
+
kind: "unsupported",
|
|
387
|
+
slide,
|
|
388
|
+
shapeId,
|
|
389
|
+
description: "grouped shape geometry"
|
|
390
|
+
});
|
|
391
|
+
else if (edits.length) changes.push({
|
|
392
|
+
kind: "geometry",
|
|
393
|
+
slide,
|
|
394
|
+
shapeId,
|
|
395
|
+
edits
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
for (const shapeId of before.keys()) if (!after.has(shapeId)) changes.push({
|
|
399
|
+
kind: "unsupported",
|
|
400
|
+
slide,
|
|
401
|
+
shapeId,
|
|
402
|
+
description: "shape removed"
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return changes;
|
|
406
|
+
}
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/source-sync.ts
|
|
409
|
+
const key = (change) => `${change.kind}:${change.slide}:${change.shapeId}:${change.kind === "unsupported" ? change.description : ""}`;
|
|
410
|
+
/**
|
|
411
|
+
* Writes the editor's changes back into the TSX source as typed prop edits.
|
|
412
|
+
* Every write is verified by rebuilding: the changes it claims must disappear
|
|
413
|
+
* from the diff and no other change may appear, or every file is restored.
|
|
414
|
+
*/
|
|
415
|
+
function createSourceSync(entry, source, rebuild) {
|
|
416
|
+
let busy = false;
|
|
417
|
+
async function localFile(fileName) {
|
|
418
|
+
const root = await realpath(dirname(entry));
|
|
419
|
+
let path;
|
|
420
|
+
try {
|
|
421
|
+
path = await realpath(fileName);
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const local = relative(root, path);
|
|
426
|
+
if (local.startsWith("..") || isAbsolute(local) || local.split(/[\\/]/).includes("node_modules")) return null;
|
|
427
|
+
return path;
|
|
428
|
+
}
|
|
429
|
+
return { async sync(edited) {
|
|
430
|
+
if (busy) throw new Error("A source write-back is already running.");
|
|
431
|
+
busy = true;
|
|
432
|
+
try {
|
|
433
|
+
const built = source();
|
|
434
|
+
if (!built?.shapeSources) throw new Error("The source deck is not built yet.");
|
|
435
|
+
const shapeSources = built.shapeSources;
|
|
436
|
+
const editedDeck = await loadPresentation(edited);
|
|
437
|
+
const changes = diffDecks(await loadPresentation(built.bytes), editedDeck);
|
|
438
|
+
const uses = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const bySlide of Object.values(shapeSources)) for (const chain of Object.values(bySlide)) {
|
|
440
|
+
const at = chain.at(-1);
|
|
441
|
+
if (at) {
|
|
442
|
+
const id = `${at.fileName}:${at.lineNumber}:${at.columnNumber}`;
|
|
443
|
+
uses.set(id, (uses.get(id) ?? 0) + 1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const pending = [];
|
|
447
|
+
const targets = [];
|
|
448
|
+
for (const change of changes) {
|
|
449
|
+
if (change.kind !== "geometry") {
|
|
450
|
+
pending.push(change);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const anchor = shapeSources[change.slide]?.[change.shapeId]?.at(-1);
|
|
454
|
+
if (!anchor) {
|
|
455
|
+
pending.push({
|
|
456
|
+
...change,
|
|
457
|
+
reason: "no source element"
|
|
458
|
+
});
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if ((uses.get(`${anchor.fileName}:${anchor.lineNumber}:${anchor.columnNumber}`) ?? 0) > 1) {
|
|
462
|
+
pending.push({
|
|
463
|
+
...change,
|
|
464
|
+
reason: "element makes several shapes"
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
targets.push({
|
|
469
|
+
change,
|
|
470
|
+
anchor
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
474
|
+
for (const target of targets) {
|
|
475
|
+
const path = await localFile(target.anchor.fileName);
|
|
476
|
+
if (!path) {
|
|
477
|
+
pending.push({
|
|
478
|
+
...target.change,
|
|
479
|
+
reason: "source outside the project"
|
|
480
|
+
});
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
byFile.set(path, [...byFile.get(path) ?? [], target]);
|
|
484
|
+
}
|
|
485
|
+
const files = [];
|
|
486
|
+
const written = [];
|
|
487
|
+
for (const [path, fileTargets] of byFile) {
|
|
488
|
+
const before = await readFile(path, "utf8");
|
|
489
|
+
let text = before;
|
|
490
|
+
fileTargets.sort((a, b) => b.anchor.lineNumber - a.anchor.lineNumber || b.anchor.columnNumber - a.anchor.columnNumber);
|
|
491
|
+
for (const { change, anchor } of fileTargets) {
|
|
492
|
+
const plan = planPropEdit({
|
|
493
|
+
file: {
|
|
494
|
+
path,
|
|
495
|
+
source: text
|
|
496
|
+
},
|
|
497
|
+
anchor: {
|
|
498
|
+
path,
|
|
499
|
+
lineNumber: anchor.lineNumber,
|
|
500
|
+
columnNumber: anchor.columnNumber
|
|
501
|
+
},
|
|
502
|
+
edits: change.edits
|
|
503
|
+
});
|
|
504
|
+
if (!plan.ok) {
|
|
505
|
+
pending.push({
|
|
506
|
+
...change,
|
|
507
|
+
reason: plan.reason
|
|
231
508
|
});
|
|
509
|
+
continue;
|
|
232
510
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
511
|
+
text = plan.change.after;
|
|
512
|
+
written.push(change);
|
|
513
|
+
}
|
|
514
|
+
if (text !== before) files.push({
|
|
515
|
+
path,
|
|
516
|
+
before,
|
|
517
|
+
after: text
|
|
518
|
+
});
|
|
236
519
|
}
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
520
|
+
if (files.length === 0) return {
|
|
521
|
+
written: [],
|
|
522
|
+
pending,
|
|
523
|
+
complete: false
|
|
524
|
+
};
|
|
525
|
+
const restore = async () => {
|
|
526
|
+
for (const file of files) if (await readFile(file.path, "utf8") !== file.after) throw new Error("Source changed during write-back. Your newer changes were preserved.");
|
|
527
|
+
for (const file of files) await writeFile(file.path, file.before);
|
|
528
|
+
await rebuild();
|
|
529
|
+
};
|
|
530
|
+
for (const file of files) if (await readFile(file.path, "utf8") !== file.before) throw new Error("Source changed. Save again.");
|
|
531
|
+
for (const file of files) await writeFile(file.path, file.after);
|
|
241
532
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
533
|
+
const error = await rebuild();
|
|
534
|
+
if (error) throw new Error("The written source did not build: " + error);
|
|
535
|
+
const rebuilt = source();
|
|
536
|
+
if (!rebuilt) throw new Error("The written source did not build.");
|
|
537
|
+
const remaining = diffDecks(await loadPresentation(rebuilt.bytes), editedDeck);
|
|
538
|
+
const expected = new Set(pending.map(key));
|
|
539
|
+
if (remaining.some((change) => !expected.has(key(change)))) throw new Error("The written source changed more than the edited shapes.");
|
|
540
|
+
return {
|
|
541
|
+
written,
|
|
542
|
+
pending,
|
|
543
|
+
complete: remaining.length === 0 && sourceFingerprint(rebuilt.bytes) === sourceFingerprint(edited)
|
|
544
|
+
};
|
|
246
545
|
} catch (error) {
|
|
247
|
-
await restore(
|
|
248
|
-
await verify();
|
|
546
|
+
await restore();
|
|
249
547
|
throw error;
|
|
250
548
|
}
|
|
251
549
|
} finally {
|
|
@@ -323,65 +621,365 @@ function createVisualReviewer(entry, slides) {
|
|
|
323
621
|
};
|
|
324
622
|
}
|
|
325
623
|
//#endregion
|
|
624
|
+
//#region src/preview-i18n.ts
|
|
625
|
+
/** Labels for the preview shell; the editor sends its active locale via editor-focus. */
|
|
626
|
+
const previewI18nScript = `
|
|
627
|
+
let previewLocale=navigator.language?.toLowerCase().startsWith('ja')?'ja':'en';
|
|
628
|
+
try{const stored=localStorage.getItem('ok-editor-locale');if(stored==='ja'||stored==='en')previewLocale=stored;}catch{}
|
|
629
|
+
let connectionLost=false;
|
|
630
|
+
const previewJapanese={
|
|
631
|
+
'Normal':'標準','Normal (Esc)':'標準 (Esc)','Agents':'エージェント','Close Agents':'エージェントを閉じる','Presentation views':'プレゼンテーションの表示','Present':'プレゼンテーション','Presenter view':'発表者ビュー',
|
|
632
|
+
'Download PPTX':'PPTXをダウンロード','Slides':'スライド','Slide viewer':'スライドビューアー','Presentation editor':'プレゼンテーション編集',
|
|
633
|
+
'Chat width':'チャットの幅','Drag to resize · Double-click to reset':'ドラッグで幅を変更・ダブルクリックでリセット',
|
|
634
|
+
'Previous slide':'前のスライド','Next slide':'次のスライド','Zoom':'ズーム','Fit':'画面に合わせる',
|
|
635
|
+
'Exit · Esc':'終了 · Esc','Exit view · Esc':'表示を終了 · Esc','Slide position':'スライドの位置','No slides':'スライドがありません','Whole project':'プロジェクト全体',
|
|
636
|
+
'Waiting for slides…':'スライドを待っています…','Building…':'ビルド中…','Updating…':'更新中…','Reconnecting…':'再接続中…',
|
|
637
|
+
'Build failed · showing last successful output':'ビルドに失敗しました・最後に成功した結果を表示中',
|
|
638
|
+
'Changes appear automatically · Text can be selected and copied':'変更は自動的に反映されます・文字を選択してコピーできます',
|
|
639
|
+
'Skipped during presentation':'プレゼンテーションでスキップされます',
|
|
640
|
+
'Allow popups to open presenter view.':'発表者ビューを開くにはポップアップを許可してください。',
|
|
641
|
+
'Office Kit — PowerPoint preview':'Office Kit — PowerPoint プレビュー'
|
|
642
|
+
};
|
|
643
|
+
const pt=key=>previewLocale==='ja'?(previewJapanese[key]??key):key;
|
|
644
|
+
const slideLabel=i=>previewLocale==='ja'?'スライド '+(i+1):'Slide '+(i+1);
|
|
645
|
+
const slideCount=()=>!state.slides.length?pt('No slides'):previewLocale==='ja'?'スライド '+(index+1)+' / '+state.slides.length:'Slide '+(index+1)+' of '+state.slides.length;
|
|
646
|
+
function updatePreviewStatus(){
|
|
647
|
+
byId('status').textContent=connectionLost?pt('Reconnecting…'):state.error?pt('Build failed · showing last successful output'):state.building?pt('Updating…'):previewLocale==='ja'?state.slides.length+' スライド・ライブ':state.slides.length+' slides · Live';
|
|
648
|
+
}
|
|
649
|
+
function updatePreviewLabels(){
|
|
650
|
+
document.documentElement.lang=previewLocale;document.title=pt('Office Kit — PowerPoint preview');
|
|
651
|
+
const labels={'#present':'Present','#presenter':'Presenter view','.download':'Download PPTX','#toggle-chat span':'Agents','#agents-title':'Agents','.filmstrip h2':'Slides','#empty':'Waiting for slides…','footer .hint':'Changes appear automatically · Text can be selected and copied','label[for="zoom"]':'Zoom','#zoom option[value="fit"]':'Fit'};
|
|
652
|
+
for(const [selector,key] of Object.entries(labels))document.querySelector(selector).textContent=pt(key);
|
|
653
|
+
for(const [selector,key] of Object.entries({'.filmstrip':'Slides','main':'Slide viewer','#chat-resizer':'Chat width','#close-chat':'Close Agents','#toggle-editor':'Normal','.status-views':'Presentation views','#prev':'Previous slide','#present-prev':'Previous slide','#next':'Next slide','#present-next':'Next slide'}))document.querySelector(selector).setAttribute('aria-label',pt(key));
|
|
654
|
+
byId('editor-frame').title=pt('Presentation editor');byId('chat-resizer').title=pt('Drag to resize · Double-click to reset');
|
|
655
|
+
byId('exit-present').textContent=pt('Exit · Esc');
|
|
656
|
+
byId('close-chat').title=pt('Close Agents');byId('toggle-editor').title=pt('Normal (Esc)');
|
|
657
|
+
byId('count').textContent=slideCount();byId('present-count').textContent=slideCount();
|
|
658
|
+
updateAnimationNotice();
|
|
659
|
+
slide.setAttribute('aria-label',slideLabel(index));
|
|
660
|
+
for(const [i,item] of Array.from(thumbnails.children).entries()){
|
|
661
|
+
item.firstElementChild.setAttribute('aria-label',slideLabel(i));
|
|
662
|
+
item.firstElementChild.title=state.hiddenSlides?.[i]?pt('Skipped during presentation'):'';
|
|
663
|
+
}
|
|
664
|
+
updateChatWidthAria();updatePreviewStatus();
|
|
665
|
+
}
|
|
666
|
+
`;
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/transition-script.ts
|
|
669
|
+
/** Presentation-only visual transitions. All layers stay inside the clipped slide. */
|
|
670
|
+
const transitionScript = `
|
|
671
|
+
let transitionAnimations=[],transitionCleanup;
|
|
672
|
+
const reducedMotion=matchMedia('(prefers-reduced-motion: reduce)');
|
|
673
|
+
function cancelTransition(){
|
|
674
|
+
const cleanup=transitionCleanup;transitionCleanup=undefined;
|
|
675
|
+
for(const animation of transitionAnimations)animation.cancel();
|
|
676
|
+
transitionAnimations=[];cleanup?.();
|
|
677
|
+
}
|
|
678
|
+
reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches){cancelTransition();scheduleAdvance();}});
|
|
679
|
+
function renderSlide(svg,options){
|
|
680
|
+
const previous=displayedSvg;
|
|
681
|
+
cancelTransition();
|
|
682
|
+
// What the viewer last saw, not the drawing the slide started as: leaving a
|
|
683
|
+
// slide part-way through its build must not flash the effects still to come.
|
|
684
|
+
const previousMarkup=canvas.innerHTML;
|
|
685
|
+
displayedSvg=svg;
|
|
686
|
+
const style='<style>svg{display:block;width:100%;height:100%}.transition-layer{position:absolute;inset:0;background:white;overflow:hidden}.transition-old{pointer-events:none}</style>';
|
|
687
|
+
canvas.innerHTML=svg?style+svg:'';
|
|
688
|
+
const playableEffects=['fade','push','wipe','cover','pull','zoom','split','circle','diamond','plus','blinds','comb','checker','strips','randomBar','dissolve','wedge','newsflash','wheel'];
|
|
689
|
+
// Choose per visit without replacing the persisted random transition or its timing.
|
|
690
|
+
const effect=options?.effect==='random'?playableEffects[Math.floor(Math.random()*playableEffects.length)]:options?.effect;
|
|
691
|
+
if(!previous||!svg||reducedMotion.matches||(effect==='cut'&&!options.thruBlack)||!(effect==='cut'||playableEffects.includes(effect)))return;
|
|
692
|
+
const incoming=document.createElement('div'),outgoing=document.createElement('div');
|
|
693
|
+
incoming.className='transition-layer';incoming.innerHTML=svg;
|
|
694
|
+
outgoing.className='transition-layer transition-old';outgoing.innerHTML=previousMarkup;
|
|
695
|
+
outgoing.setAttribute('aria-hidden','true');outgoing.inert=true;
|
|
696
|
+
canvas.innerHTML=style;canvas.append(outgoing,incoming);
|
|
697
|
+
// PowerPoint plays fast/med/slow as 0.5/0.75/1 s; p14:dur overrides them.
|
|
698
|
+
const duration=options.durationMs??(options.speed==='slow'?1000:options.speed==='fast'?500:750);
|
|
699
|
+
const timing={duration,easing:'ease-in-out',fill:'both'};
|
|
700
|
+
const animate=(node,frames,easing=timing.easing)=>transitionAnimations.push(node.animate(frames,{...timing,easing}));
|
|
701
|
+
const vectors={l:[-100,0],r:[100,0],u:[0,-100],d:[0,100],lu:[-100,-100],ru:[100,-100],ld:[-100,100],rd:[100,100]};
|
|
702
|
+
const [x,y]=vectors[options.direction]??vectors.l;
|
|
703
|
+
const shift=(a,b)=>'translate('+a+'%,'+b+'%)';
|
|
704
|
+
if(effect==='cut'){
|
|
705
|
+
slide.style.background='black';outgoing.style.visibility='hidden';
|
|
706
|
+
animate(incoming,[{opacity:0},{opacity:1}],'steps(1,end)');
|
|
707
|
+
}else if(effect==='fade'){
|
|
708
|
+
if(options.thruBlack){
|
|
709
|
+
outgoing.style.background='black';incoming.style.background='black';slide.style.background='black';
|
|
710
|
+
animate(outgoing,[{opacity:1,offset:0},{opacity:0,offset:.5},{opacity:0,offset:1}]);
|
|
711
|
+
animate(incoming,[{opacity:0,offset:0},{opacity:0,offset:.5},{opacity:1,offset:1}]);
|
|
712
|
+
}else animate(incoming,[{opacity:0},{opacity:1}]);
|
|
713
|
+
}else if(effect==='push'||effect==='cover'){
|
|
714
|
+
animate(incoming,[{transform:shift(-x,-y)},{transform:shift(0,0)}]);
|
|
715
|
+
if(effect==='push')animate(outgoing,[{transform:shift(0,0)},{transform:shift(x,y)}]);
|
|
716
|
+
}else if(effect==='pull'){
|
|
717
|
+
canvas.append(outgoing);
|
|
718
|
+
animate(outgoing,[{transform:shift(0,0)},{transform:shift(x,y)}]);
|
|
719
|
+
}else if(effect==='wipe'){
|
|
720
|
+
const clips={l:'inset(0 0 0 100%)',r:'inset(0 100% 0 0)',u:'inset(100% 0 0 0)',d:'inset(0 0 100% 0)'};
|
|
721
|
+
animate(incoming,[{clipPath:clips[options.direction]??clips.l},{clipPath:'inset(0 0 0 0)'}]);
|
|
722
|
+
}else if(effect==='blinds'||effect==='comb'){
|
|
723
|
+
const vertical=options.direction==='vert';
|
|
724
|
+
const bands=(progress)=>{
|
|
725
|
+
const points=[];
|
|
726
|
+
for(let band=0;band<8;band++){
|
|
727
|
+
const start=band*12.5,end=start+12.5;
|
|
728
|
+
const left=effect==='comb'&&band%2?100*(1-progress):0;
|
|
729
|
+
const right=effect==='comb'&&band%2?100:100*progress;
|
|
730
|
+
const rect=effect==='blinds'
|
|
731
|
+
?[[start,0],[start+12.5*progress,0],[start+12.5*progress,100],[start,100]]
|
|
732
|
+
:[[left,start],[right,start],[right,end],[left,end]];
|
|
733
|
+
// Return along the same bridge so disconnected bands share one clip polygon.
|
|
734
|
+
points.push([0,0],...rect,rect[0],[0,0]);
|
|
735
|
+
}
|
|
736
|
+
return 'polygon('+points.map(([x,y])=>vertical?y+'% '+x+'%':x+'% '+y+'%').join(',')+')';
|
|
737
|
+
};
|
|
738
|
+
animate(incoming,[{clipPath:bands(0)},{clipPath:bands(1)}]);
|
|
739
|
+
}else if(effect==='checker'){
|
|
740
|
+
const cells=(progress)=>{
|
|
741
|
+
const points=[];
|
|
742
|
+
for(let row=0;row<6;row++)for(let col=0;col<8;col++){
|
|
743
|
+
const amount=Math.max(0,Math.min(1,2*progress-(row+col)%2));
|
|
744
|
+
const left=col*100/8,top=row*100/6;
|
|
745
|
+
const right=(col+(options.direction==='vert'?1:amount))*100/8;
|
|
746
|
+
const bottom=(row+(options.direction==='vert'?amount:1))*100/6;
|
|
747
|
+
// Keep zero-area bridges on cell boundaries to avoid diagonal antialiasing artifacts.
|
|
748
|
+
points.push([0,0],[left,0],[left,top],[right,top],[right,bottom],[left,bottom],[left,top],[left,0],[0,0]);
|
|
749
|
+
}
|
|
750
|
+
return 'polygon('+points.map(([x,y])=>x+'% '+y+'%').join(',')+')';
|
|
751
|
+
};
|
|
752
|
+
animate(incoming,[{clipPath:cells(0)},{clipPath:cells(.5)},{clipPath:cells(1)}]);
|
|
753
|
+
}else if(effect==='newsflash'){
|
|
754
|
+
animate(incoming,[{transform:'rotate(720deg) scale(0)'},{transform:'rotate(0deg) scale(1)'}]);
|
|
755
|
+
}else if(effect==='wheel'){
|
|
756
|
+
const spokes=options.spokes??4;
|
|
757
|
+
if(spokes===0)animate(incoming,[{opacity:0},{opacity:1}]);
|
|
758
|
+
else{
|
|
759
|
+
const stages=64,sector=360/spokes;
|
|
760
|
+
// A repeating mask keeps memory bounded even for the schema's maximum unsigned integer.
|
|
761
|
+
const mask=(progress)=>'repeating-conic-gradient(#000 0deg '+sector*progress+'deg,transparent '+sector*progress+'deg '+sector+'deg)';
|
|
762
|
+
// Gradient images are discrete values; explicit stages avoid a halfway-only image swap.
|
|
763
|
+
animate(incoming,Array.from({length:stages+1},(_,step)=>({maskImage:mask(step/stages),offset:step/stages,easing:'steps(1,end)'})),'linear');
|
|
764
|
+
}
|
|
765
|
+
}else if(effect==='wedge'){
|
|
766
|
+
const segments=32;
|
|
767
|
+
const wedge=(progress)=>{
|
|
768
|
+
const points=['50% 50%'];
|
|
769
|
+
for(let point=0;point<=segments;point++){
|
|
770
|
+
const angle=-Math.PI/2+Math.PI*progress*(2*point/segments-1);
|
|
771
|
+
points.push((50+100*Math.cos(angle))+'% '+(50+100*Math.sin(angle))+'%');
|
|
772
|
+
}
|
|
773
|
+
return 'polygon('+points.join(',')+')';
|
|
774
|
+
};
|
|
775
|
+
// Intermediate angles keep the radial boundaries rotating instead of shrinking across the slide.
|
|
776
|
+
animate(incoming,Array.from({length:segments+1},(_,step)=>({clipPath:wedge(step/segments),offset:step/segments})));
|
|
777
|
+
}else if(effect==='dissolve'){
|
|
778
|
+
const columns=32,stages=16,bounds=incoming.getBoundingClientRect();
|
|
779
|
+
const rows=Math.max(1,Math.round(columns*bounds.height/bounds.width)),count=columns*rows;
|
|
780
|
+
const order=Array.from({length:count},(_,i)=>i);
|
|
781
|
+
for(let i=count-1;i>0;i--){
|
|
782
|
+
const j=Math.floor(Math.random()*(i+1));
|
|
783
|
+
[order[i],order[j]]=[order[j],order[i]];
|
|
784
|
+
}
|
|
785
|
+
const cells=(step)=>{
|
|
786
|
+
const points=['0% 0%'];
|
|
787
|
+
for(let i=0;i<Math.floor(count*step/stages);i++){
|
|
788
|
+
const cell=order[i],col=cell%columns,row=Math.floor(cell/columns);
|
|
789
|
+
const left=100*col/columns,right=100*(col+1)/columns,top=100*row/rows,bottom=100*(row+1)/rows;
|
|
790
|
+
// Retrace bridges along cell boundaries to avoid diagonal clipping artifacts.
|
|
791
|
+
for(const [x,y] of [[left,0],[left,top],[right,top],[right,bottom],[left,bottom],[left,top],[left,0],[0,0]])
|
|
792
|
+
points.push(x+'% '+y+'%');
|
|
793
|
+
}
|
|
794
|
+
return step===0?'polygon(0% 0%,0% 0%,0% 0%)':'polygon('+points.join(',')+')';
|
|
795
|
+
};
|
|
796
|
+
// Discrete masks add whole squares instead of morphing unrelated polygon vertices.
|
|
797
|
+
animate(incoming,Array.from({length:stages+1},(_,step)=>({clipPath:cells(step),offset:step/stages,easing:'steps(1,end)'})),'linear');
|
|
798
|
+
}else if(effect==='randomBar'){
|
|
799
|
+
const bands=16,vertical=options.direction==='vert';
|
|
800
|
+
const ranks=Array.from({length:bands},(_,i)=>i);
|
|
801
|
+
for(let i=bands-1;i>0;i--){
|
|
802
|
+
const j=Math.floor(Math.random()*(i+1));
|
|
803
|
+
[ranks[i],ranks[j]]=[ranks[j],ranks[i]];
|
|
804
|
+
}
|
|
805
|
+
const bars=(step)=>{
|
|
806
|
+
const points=[];
|
|
807
|
+
for(let band=0;band<bands;band++){
|
|
808
|
+
const top=100*band/bands,bottom=top+100/bands*Math.max(0,Math.min(1,step-ranks[band]));
|
|
809
|
+
// Retrace the edge bridge so disconnected bars do not clip diagonal slivers.
|
|
810
|
+
for(const [x,y] of [[0,0],[0,top],[100,top],[100,bottom],[0,bottom],[0,top],[0,0]])
|
|
811
|
+
points.push((vertical?y:x)+'% '+(vertical?x:y)+'%');
|
|
812
|
+
}
|
|
813
|
+
return 'polygon('+points.join(',')+')';
|
|
814
|
+
};
|
|
815
|
+
animate(incoming,Array.from({length:bands+1},(_,step)=>({clipPath:bars(step),offset:step/bands})));
|
|
816
|
+
}else if(effect==='strips'){
|
|
817
|
+
const direction=options.direction??'lu';
|
|
818
|
+
const strips=(progress)=>{
|
|
819
|
+
const points=[];
|
|
820
|
+
for(let band=0;band<8;band++){
|
|
821
|
+
const top=band*12.5,bottom=(band+1)*12.5;
|
|
822
|
+
const right=100*Math.max(0,Math.min(1,(15*progress-band)/8));
|
|
823
|
+
points.push([0,0],[0,top],[right,top],[right,bottom],[0,bottom],[0,top],[0,0]);
|
|
824
|
+
}
|
|
825
|
+
return 'polygon('+points.map(([x,y])=>(direction.startsWith('l')?100-x:x)+'% '+(direction.endsWith('u')?100-y:y)+'%').join(',')+')';
|
|
826
|
+
};
|
|
827
|
+
// Include every band's start/end so interpolation preserves the staggered wipe.
|
|
828
|
+
animate(incoming,Array.from({length:16},(_,step)=>({clipPath:strips(step/15),offset:step/15})));
|
|
829
|
+
}else if(effect==='split'){
|
|
830
|
+
const collapsed=options.orientation==='vert'?'inset(0 50%)':'inset(50% 0)';
|
|
831
|
+
const expanded='inset(0 0)';
|
|
832
|
+
if(options.direction==='in'){
|
|
833
|
+
canvas.append(outgoing);
|
|
834
|
+
animate(outgoing,[{clipPath:expanded},{clipPath:collapsed}]);
|
|
835
|
+
}else animate(incoming,[{clipPath:collapsed},{clipPath:expanded}]);
|
|
836
|
+
}else if(effect==='circle'){
|
|
837
|
+
animate(incoming,[{clipPath:'circle(0% at 50% 50%)'},{clipPath:'circle(100% at 50% 50%)'}]);
|
|
838
|
+
}else if(effect==='diamond'){
|
|
839
|
+
animate(incoming,[{clipPath:'polygon(50% 50%,50% 50%,50% 50%,50% 50%)'},{clipPath:'polygon(50% -50%,150% 50%,50% 150%,-50% 50%)'}]);
|
|
840
|
+
}else if(effect==='plus'){
|
|
841
|
+
const cross=(low,high)=>'polygon('+low+'% 0%,'+high+'% 0%,'+high+'% '+low+'%,100% '+low+'%,100% '+high+'%,'+high+'% '+high+'%,'+high+'% 100%,'+low+'% 100%,'+low+'% '+high+'%,0% '+high+'%,0% '+low+'%,'+low+'% '+low+'%)';
|
|
842
|
+
animate(incoming,[{clipPath:cross(50,50)},{clipPath:cross(0,100)}]);
|
|
843
|
+
}else if(effect==='zoom'){
|
|
844
|
+
animate(incoming,[{transform:options.direction==='out'?'scale(2)':'scale(0.1)',opacity:0},{transform:'scale(1)',opacity:1}]);
|
|
845
|
+
}
|
|
846
|
+
clearTimeout(advanceTimer);advanceKey=null;
|
|
847
|
+
const cleanup=()=>{
|
|
848
|
+
outgoing.remove();
|
|
849
|
+
// The incoming layer keeps its own nodes rather than being re-parsed into
|
|
850
|
+
// the canvas: the slide's build may already be running on them — a leading
|
|
851
|
+
// 'with previous' effect starts as the slide appears — and new nodes would
|
|
852
|
+
// lose both the animation and what it was hiding. Dropping the class is
|
|
853
|
+
// enough to leave no transition layer behind.
|
|
854
|
+
incoming.className='';incoming.style.cssText='position:absolute;inset:0';
|
|
855
|
+
slide.style.background='';
|
|
856
|
+
};
|
|
857
|
+
transitionCleanup=cleanup;
|
|
858
|
+
Promise.all(transitionAnimations.map(animation=>animation.finished)).then(()=>{
|
|
859
|
+
if(transitionCleanup!==cleanup)return;
|
|
860
|
+
cancelTransition();scheduleAdvance();
|
|
861
|
+
},()=>{});
|
|
862
|
+
}
|
|
863
|
+
`;
|
|
864
|
+
//#endregion
|
|
326
865
|
//#region src/styles.ts
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
866
|
+
const tokenStyles = `:root{
|
|
867
|
+
--ok-accent:#b4235a;--ok-accent-2:#d6336c;--ok-bg:#f4f5f7;--ok-panel:#ffffff;--ok-panel-2:#f9fafb;--ok-ribbon:#f7f8fa;--ok-ribbon-active:#ffffff;
|
|
868
|
+
--ok-border:#dfe2e7;--ok-border-strong:#c9cdd6;--ok-text:#15171c;--ok-text-2:#545b69;--ok-text-3:#8a909c;--ok-hover:#eef0f3;--ok-selected:#fdebf1;--ok-selected-border:#b4235a;
|
|
869
|
+
--ok-canvas-bg:#d3d6dc;--ok-danger:#c92a2a;--ok-radius:3px;--ok-radius-lg:6px;
|
|
870
|
+
--ok-shadow:0 1px 2px rgba(16, 18, 23, 0.08), 0 0 0 1px rgba(16, 18, 23, 0.04);--ok-shadow-lg:0 12px 32px -8px rgba(16, 18, 23, 0.22), 0 2px 6px rgba(16, 18, 23, 0.08);
|
|
871
|
+
--ok-font:system-ui, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;--ok-mono:ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
872
|
+
--shell-alert-bg:#fff0cd;--shell-alert-text:#583b00;
|
|
873
|
+
color-scheme:light;
|
|
874
|
+
}
|
|
875
|
+
@media (prefers-color-scheme: dark){:root{
|
|
876
|
+
color-scheme:dark;
|
|
877
|
+
--ok-accent:#e85a90;--ok-accent-2:#ff9cbf;--ok-bg:#1f1f1f;--ok-panel:#2b2b2b;--ok-panel-2:#252525;--ok-ribbon:#2a2a2a;--ok-ribbon-active:#333333;
|
|
878
|
+
--ok-border:#3d3d3d;--ok-border-strong:#555555;--ok-text:#f0f0f0;--ok-text-2:#c4c4c4;--ok-text-3:#8f8f8f;--ok-hover:#3a3a3a;--ok-selected:#3a2230;--ok-selected-border:#ff7aa8;
|
|
879
|
+
--ok-canvas-bg:#1b1b1b;--ok-danger:#f1707a;
|
|
880
|
+
--ok-shadow:0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.04);--ok-shadow-lg:0 12px 32px -8px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.35);
|
|
881
|
+
--shell-alert-bg:#4a3a12;--shell-alert-text:#ffe2a3;
|
|
882
|
+
}}
|
|
883
|
+
*{box-sizing:border-box}
|
|
884
|
+
body{margin:0;background:var(--ok-bg);color:var(--ok-text);font:13px var(--ok-font);-webkit-font-smoothing:antialiased}
|
|
885
|
+
button,a,select,textarea{font:inherit;color:inherit}
|
|
886
|
+
button,select,.download{border:1px solid transparent;border-radius:var(--ok-radius);background:transparent;padding:3px 8px;text-decoration:none;cursor:pointer;white-space:nowrap}
|
|
887
|
+
button:hover:not(:disabled),.download:hover{background:var(--ok-hover);border-color:var(--ok-border)}
|
|
888
|
+
button:active:not(:disabled){background:var(--ok-selected)}
|
|
889
|
+
button[aria-pressed="true"]{background:var(--ok-selected);border-color:var(--ok-selected-border)}
|
|
890
|
+
button:disabled{color:var(--ok-text-3);cursor:default}
|
|
891
|
+
select{border-color:var(--ok-border-strong);background:var(--ok-panel);padding:2px 6px}
|
|
892
|
+
select:hover:not(:disabled){border-color:var(--ok-border-strong)}
|
|
893
|
+
:focus-visible{outline:2px solid var(--ok-selected-border);outline-offset:1px}
|
|
894
|
+
.primary{background:var(--ok-accent);border-color:var(--ok-accent);color:#fff}
|
|
895
|
+
.primary:hover:not(:disabled){background:var(--ok-accent-2);border-color:var(--ok-accent-2)}
|
|
896
|
+
.primary:disabled{background:var(--ok-hover);border-color:var(--ok-border);color:var(--ok-text-3)}
|
|
897
|
+
[hidden]{display:none!important}
|
|
898
|
+
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
|
899
|
+
`;
|
|
900
|
+
const previewStyles = `${tokenStyles}
|
|
901
|
+
body{height:100dvh;overflow:hidden;display:grid;grid-template-rows:minmax(0,1fr) 26px}
|
|
335
902
|
.workspace{--filmstrip-width:190px;--chat-width:38vw;display:grid;grid-template-columns:var(--filmstrip-width) minmax(0,1fr) clamp(280px,var(--chat-width),calc(100vw - var(--filmstrip-width) - 240px));min-height:0}
|
|
336
|
-
.filmstrip{background
|
|
337
|
-
.filmstrip h2{margin:
|
|
338
|
-
#thumbnails{display:flex;flex-direction:column;gap:
|
|
339
|
-
.thumbnail{display:flex;align-items:flex-start;gap:
|
|
340
|
-
.
|
|
341
|
-
.
|
|
342
|
-
.thumbnail
|
|
903
|
+
.filmstrip{background:var(--ok-panel-2);border-right:1px solid var(--ok-border);overflow:auto;overscroll-behavior:contain;padding:8px}
|
|
904
|
+
.filmstrip h2{margin:4px 0 8px 26px;font-size:11px;font-weight:600;color:var(--ok-text-2)}
|
|
905
|
+
#thumbnails{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
|
|
906
|
+
.thumbnail{display:flex;align-items:flex-start;gap:6px;width:100%;padding:6px 6px 8px 4px;border:0;background:transparent;border-radius:0;text-align:left}
|
|
907
|
+
.thumbnail:hover:not(:disabled){background:transparent;border-color:transparent}
|
|
908
|
+
.slide-number{width:16px;flex:none;text-align:right;font-size:11px;color:var(--ok-text-2);padding-top:2px}
|
|
909
|
+
.thumbnail img{display:block;min-width:0;width:calc(100% - 22px);background:white;border:1px solid var(--ok-border);border-radius:4px;aspect-ratio:var(--slide-ratio,16/9);object-fit:contain}
|
|
910
|
+
.thumbnail:hover img{outline:3px solid var(--ok-border-strong);outline-offset:2px}
|
|
911
|
+
.thumbnail[aria-current="true"] img{outline:3px solid var(--ok-accent);outline-offset:2px}.thumbnail[aria-current="true"] .slide-number{color:var(--ok-accent);font-weight:600}
|
|
912
|
+
.thumbnail[data-skipped="true"] .slide-number{text-decoration:line-through}
|
|
913
|
+
.thumbnail[data-skipped="true"] img{opacity:.6}
|
|
343
914
|
main{min-width:0;min-height:0;display:flex;flex-direction:column}
|
|
344
|
-
#error{flex:none;max-height:30%;overflow:auto;background
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
footer{
|
|
351
|
-
#
|
|
915
|
+
#error{flex:none;max-height:30%;overflow:auto;background:var(--shell-alert-bg);color:var(--shell-alert-text);margin:0;padding:8px 12px;font:12px var(--ok-mono);white-space:pre-wrap;border-bottom:1px solid var(--ok-border)}
|
|
916
|
+
#stage{background:var(--ok-canvas-bg);flex:1;min-height:0;overflow:auto;display:flex;padding:32px;overscroll-behavior:contain}
|
|
917
|
+
#slide{position:relative;flex:none;margin:auto;background:white;box-shadow:var(--ok-shadow-lg);overflow:hidden;user-select:text;-webkit-user-select:text}
|
|
918
|
+
#empty{margin:auto;color:var(--ok-text-2)}
|
|
919
|
+
footer{display:flex;align-items:center;gap:6px;padding:0 12px;background:var(--ok-ribbon);border-top:1px solid var(--ok-border);color:var(--ok-text-2);font-size:11px;min-width:0;white-space:nowrap}
|
|
920
|
+
footer button,footer .download,footer select{font-size:11px;padding:1px 6px;line-height:16px}
|
|
921
|
+
footer .hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--ok-text-3)}
|
|
922
|
+
#status{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
923
|
+
.status-views{display:flex;gap:2px}
|
|
924
|
+
footer svg{display:block;fill:none;stroke:currentColor;stroke-width:1.1}
|
|
925
|
+
#toggle-editor{padding:2px 6px}
|
|
926
|
+
#toggle-chat svg{width:13px;height:13px}#toggle-chat[aria-pressed="true"] svg{fill:var(--ok-accent);stroke:var(--ok-accent)}#toggle-chat{display:inline-flex;align-items:center;gap:4px}
|
|
927
|
+
#notice{position:fixed;z-index:40;left:50%;bottom:36px;transform:translateX(-50%);max-width:min(480px,calc(100vw - 32px));padding:8px 12px;border:1px solid var(--ok-border);border-radius:var(--ok-radius-lg);background:var(--ok-panel);color:var(--ok-text);box-shadow:var(--ok-shadow-lg);font-size:12px}
|
|
352
928
|
#presentation-controls{display:none}
|
|
353
929
|
body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
|
|
354
|
-
.presenting
|
|
930
|
+
.presenting footer,.presenting .filmstrip,.presenting #chat{display:none}
|
|
355
931
|
.presenting .workspace{grid-template-columns:minmax(0,1fr)}.presenting #stage{padding:0;background:#111}.presenting #slide{box-shadow:none}
|
|
356
932
|
.presenting #presentation-controls{display:flex;position:fixed;bottom:16px;left:50%;transform:translateX(-50%);align-items:center;gap:12px;background:#202735e8;color:white;padding:6px;border-radius:8px;opacity:0;transition:opacity .15s}
|
|
357
933
|
.presenting #presentation-controls:hover,.presenting #presentation-controls:focus-within{opacity:1}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
#chat
|
|
366
|
-
|
|
934
|
+
.presenting.browse-scrollbar #stage{padding-right:18px}
|
|
935
|
+
#presentation-scrollbar{position:fixed;z-index:4;top:0;bottom:0;right:0;width:16px;touch-action:none;border:1px solid #5d6575;background:#202735e8;cursor:pointer}
|
|
936
|
+
#presentation-scrollbar span{position:absolute;left:3px;right:3px;min-height:8px;border-radius:5px;background:#c4c4c4;pointer-events:none}
|
|
937
|
+
.kiosk-presenting #present-prev,.kiosk-presenting #present-next{display:none}
|
|
938
|
+
#presentation-controls button{background:transparent;color:white;border-color:#5d6575;padding:5px 10px}
|
|
939
|
+
#presentation-controls button:hover:not(:disabled){background:#ffffff1f}
|
|
940
|
+
#present-note{max-width:38ch;color:#ffd9a8;font-size:11px;line-height:1.35}
|
|
941
|
+
#chat{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--ok-panel);color:var(--ok-text);border-left:1px solid var(--ok-border)}
|
|
942
|
+
#chat-resizer{position:absolute;left:-4px;top:0;bottom:0;width:7px;z-index:3;cursor:col-resize;touch-action:none}#chat-resizer::after{content:"";position:absolute;left:3px;top:0;bottom:0;width:1px}#chat-resizer:hover::after,#chat-resizer:focus-visible::after,.resizing-chat #chat-resizer::after{background:var(--ok-selected-border)}#chat-resizer:focus-visible{outline:none}
|
|
943
|
+
body.resizing-chat,body.resizing-chat *{cursor:col-resize!important;user-select:none!important}
|
|
944
|
+
.pane-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--ok-border);background:var(--ok-panel);flex:none}
|
|
945
|
+
.pane-head strong{font-size:13px;font-weight:600}
|
|
946
|
+
.pane-close{margin-left:auto;padding:0 4px;border:none;background:none;color:var(--ok-text-2);font-size:20px;line-height:20px}
|
|
947
|
+
.pane-close:hover:not(:disabled){background:var(--ok-hover);border-color:transparent;color:var(--ok-text)}
|
|
948
|
+
body.chat-hidden .workspace{grid-template-columns:var(--filmstrip-width) minmax(0,1fr)}body.chat-hidden #chat{display:none}
|
|
367
949
|
body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
|
|
368
|
-
@media(max-width:1000px){.workspace{--filmstrip-width:120px;--chat-width:380px}
|
|
369
|
-
@media(max-width:700px){#chat-resizer{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(160px,1fr) minmax(200px,1fr)}.filmstrip{display:none}#chat{border-top:1px solid
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
950
|
+
@media(max-width:1000px){.workspace{--filmstrip-width:120px;--chat-width:380px}#stage{padding:16px}}
|
|
951
|
+
@media(max-width:700px){#chat-resizer{display:none}.workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(160px,1fr) minmax(200px,1fr)}.filmstrip{display:none}#chat{border-left:0;border-top:1px solid var(--ok-border)}body.chat-hidden .workspace,.presenting .workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}footer .hint,#status,.download{display:none}#stage{padding:16px}}
|
|
952
|
+
`;
|
|
953
|
+
/** The agent page renders inside one agent pane of the docked Agents task pane. */
|
|
954
|
+
const agentStyles = `${tokenStyles}
|
|
955
|
+
body{display:flex;flex-direction:column;background:var(--ok-panel);height:100dvh;min-width:0;overflow:auto;overflow-x:hidden}
|
|
956
|
+
.chat-heading{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:6px 12px 2px;flex-shrink:0}
|
|
957
|
+
.chat-heading select{flex:1;min-width:100px;font-size:12px}
|
|
958
|
+
#chat-context{flex-shrink:0;margin:0;padding:4px 12px 6px;color:var(--ok-text-2);font-size:11px;border-bottom:1px solid var(--ok-border)}
|
|
375
959
|
#claude-panel,#codex-panel{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
376
|
-
#claude-panel{
|
|
377
|
-
.terminal-toolbar{display:flex;align-items:center;gap:
|
|
378
|
-
.terminal-toolbar
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
960
|
+
#claude-panel{min-height:100px}#codex-panel{min-height:240px}
|
|
961
|
+
.terminal-toolbar{display:flex;align-items:center;gap:6px;padding:6px 12px;min-height:34px;font-size:11px;color:var(--ok-text-2)}
|
|
962
|
+
.terminal-toolbar span{flex:1;overflow-wrap:anywhere}
|
|
963
|
+
.terminal-toolbar button{font-size:11px;padding:2px 8px;border-color:var(--ok-border-strong)}
|
|
964
|
+
.terminal-frame{display:flex;flex:1;min-height:0;min-width:0;padding:2px 4px 4px 12px;overflow:hidden}
|
|
965
|
+
#terminal{flex:1;width:100%;min-width:0;min-height:0;overflow:hidden;padding:0}.xterm{height:100%}
|
|
966
|
+
/* xterm's stylesheet paints the viewport black; the rows below the last full line show it. */
|
|
967
|
+
#terminal .xterm-viewport{background-color:var(--ok-panel)!important}
|
|
968
|
+
#messages{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px;overscroll-behavior:contain}
|
|
969
|
+
.chat-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55}.chat-message.user{background:var(--ok-panel-2);border:1px solid var(--ok-border);border-radius:var(--ok-radius-lg);padding:8px 10px}
|
|
970
|
+
.chat-message small{display:block;color:var(--ok-text-2);font-size:11px;margin-bottom:4px}.chat-intro{color:var(--ok-text-2);line-height:1.6;font-size:12px}
|
|
971
|
+
#chat-form{padding:8px 12px;border-top:1px solid var(--ok-border)}
|
|
972
|
+
#chat-input{resize:vertical;min-height:70px;max-height:200px;width:100%;padding:6px 8px;border:1px solid var(--ok-border-strong);border-radius:var(--ok-radius);background:var(--ok-panel);color:var(--ok-text)}
|
|
973
|
+
#chat-input:focus{outline:2px solid var(--ok-selected-border);outline-offset:-1px}
|
|
974
|
+
.chat-actions{display:flex;gap:6px;margin-top:6px;align-items:center}.chat-actions button{border-color:var(--ok-border-strong)}.chat-actions .primary{border-color:var(--ok-accent)}
|
|
975
|
+
.chat-shortcut{flex:1;color:var(--ok-text-3);font-size:10px}
|
|
976
|
+
#chat-status{padding:0 12px 8px;color:var(--ok-text-2);font-size:11px;white-space:pre-wrap;max-height:90px;overflow:auto}
|
|
977
|
+
.markdown{white-space:normal;line-height:1.6}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p{margin:0 0 .8em}
|
|
978
|
+
.markdown pre{overflow:auto;padding:8px;background:var(--ok-panel-2);border:1px solid var(--ok-border);border-radius:var(--ok-radius);white-space:pre;font-family:var(--ok-mono)}
|
|
979
|
+
.markdown code{font:.9em var(--ok-mono);background:var(--ok-panel-2);border-radius:3px;padding:1px 4px}.markdown pre code{padding:0}
|
|
980
|
+
.markdown ul,.markdown ol{padding-left:22px}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid var(--ok-border);color:var(--ok-text-2)}
|
|
981
|
+
.markdown table{display:block;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:6px 8px;border:1px solid var(--ok-border)}
|
|
982
|
+
.markdown h1,.markdown h2,.markdown h3{font-size:1.1em}.markdown a{color:var(--ok-accent);text-decoration:underline}
|
|
385
983
|
`;
|
|
386
984
|
//#endregion
|
|
387
985
|
//#region src/page.ts
|
|
@@ -392,24 +990,201 @@ const page = `<!doctype html>
|
|
|
392
990
|
<link rel="stylesheet" href="/terminal.css">
|
|
393
991
|
<style>
|
|
394
992
|
${previewStyles}
|
|
395
|
-
.slide-edit-tools{display:flex;gap:
|
|
993
|
+
.slide-edit-tools{display:flex;align-items:center;flex-wrap:wrap;gap:4px;padding:4px 12px;flex-shrink:0;background:var(--ok-ribbon);border-bottom:1px solid var(--ok-border)}.slide-edit-tools [data-undo],.slide-edit-tools [data-redo]{width:26px;height:26px;padding:0;display:grid;place-items:center;color:var(--ok-text-2)}.slide-edit-tools [data-undo]:hover:not(:disabled),.slide-edit-tools [data-redo]:hover:not(:disabled){color:var(--ok-text)}.slide-edit-tools span{font-size:11px;color:var(--ok-text-2);flex:1}.slide-edit-tools small{font-size:11px;color:var(--ok-accent)}.slide-hover{position:fixed;border:1px solid var(--ok-selected-border);pointer-events:none;z-index:19;border-radius:2px}.slide-text-input{position:fixed;z-index:22;resize:none;background:var(--ok-panel);color:var(--ok-text);border:2px solid var(--ok-selected-border);border-radius:var(--ok-radius);padding:0;line-height:1.15}.slide-selection{position:fixed;border:2px solid var(--ok-selected-border);background:color-mix(in srgb,var(--ok-selected-border) 10%,transparent);pointer-events:none;z-index:20}.slide-edit-panel{position:fixed;z-index:21;width:min(380px,calc(100vw - 16px));padding:12px;background:var(--ok-panel);color:var(--ok-text);border:1px solid var(--ok-border);border-radius:var(--ok-radius-lg);box-shadow:var(--ok-shadow-lg);display:flex;flex-direction:column;gap:10px}.selection-heading,.selection-actions{display:flex;align-items:center;gap:6px}.selection-heading strong{flex:1}.slide-edit-panel textarea{width:100%;min-height:90px;max-height:35vh;resize:vertical;background:var(--ok-panel);color:var(--ok-text);border:1px solid var(--ok-border-strong);border-radius:var(--ok-radius);padding:6px 8px}.slide-edit-panel textarea:focus{outline:2px solid var(--ok-selected-border);outline-offset:-1px}.selection-actions{flex-wrap:wrap}.selection-actions select{min-width:80px;flex:1}.selection-actions button{border-color:var(--ok-border-strong)}.slide-edit-panel small{color:var(--ok-text-2);line-height:1.5}.presenting .slide-edit-tools{display:none}
|
|
994
|
+
.editing:not(.presenting) .slide-edit-tools{display:none}
|
|
396
995
|
|
|
397
|
-
#agent-workspace{position:relative;flex:1;min-height:0;min-width:0;display:flex;overflow:auto}.agent-pane{position:absolute;
|
|
996
|
+
#agent-workspace{position:relative;flex:1;min-height:0;min-width:0;display:flex;overflow:auto}.agent-pane{position:absolute;display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;overflow:hidden}.agent-pane iframe{border:0;width:100%;flex:1;min-height:0;background:var(--ok-panel)}.agent-tools{display:flex;gap:2px;align-items:center;padding:3px 6px 3px 12px;min-height:30px;background:var(--ok-panel-2);border-bottom:1px solid var(--ok-border)}.agent-tools span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:600;color:var(--ok-text-2)}.agent-pane:focus-within .agent-tools span{color:var(--ok-text)}.agent-tools button{width:24px;height:24px;padding:0;display:grid;place-items:center;color:var(--ok-text-2)}.agent-tools button:hover:not(:disabled){color:var(--ok-text)}.agent-tools svg{width:14px;height:14px}.agent-divider{position:absolute;z-index:2;background:transparent;cursor:col-resize;touch-action:none}.agent-divider.vertical{cursor:row-resize}.agent-divider::after{content:"";position:absolute;background:var(--ok-border)}.agent-divider.horizontal::after{top:0;bottom:0;left:50%;width:1px}.agent-divider.vertical::after{left:0;right:0;top:50%;height:1px}.agent-divider:hover::after,.agent-divider:focus-visible::after{background:var(--ok-selected-border)}.agent-divider.horizontal:hover::after,.agent-divider.horizontal:focus-visible::after{width:2px}.agent-divider.vertical:hover::after,.agent-divider.vertical:focus-visible::after{height:2px}.agent-divider:focus-visible{outline:none}.splitting-agents iframe,.resizing-chat iframe{pointer-events:none}
|
|
997
|
+
#editor-frame{display:none;border:0;width:100%;height:100%;flex:1;min-height:0}
|
|
998
|
+
body.editing:not(.presenting){grid-template-rows:minmax(0,1fr)}
|
|
999
|
+
.editing:not(.presenting) .workspace{--filmstrip-width:0px;grid-template-columns:minmax(0,1fr) clamp(280px,var(--chat-width),calc(100vw - 500px))}
|
|
1000
|
+
.editing.chat-hidden:not(.presenting) .workspace{grid-template-columns:minmax(0,1fr)}
|
|
1001
|
+
.editing:not(.presenting) .filmstrip,.editing:not(.presenting) footer,.editing:not(.presenting) #stage{display:none}
|
|
1002
|
+
.editing:not(.presenting) #editor-frame{display:block}
|
|
1003
|
+
/* Too narrow to dock beside the editor: the pane floats over its right edge. */
|
|
1004
|
+
@media(max-width:900px){.editing:not(.presenting) .workspace{grid-template-columns:minmax(0,1fr)}.editing:not(.presenting) #chat{position:fixed;z-index:30;top:0;right:0;bottom:0;width:min(380px,100vw);box-shadow:var(--ok-shadow-lg)}.editing:not(.presenting) #chat-resizer{display:none}}
|
|
398
1005
|
</style>
|
|
399
|
-
<
|
|
1006
|
+
<body class="editing chat-hidden">
|
|
400
1007
|
<div class="workspace">
|
|
401
1008
|
<nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
|
|
402
|
-
<main aria-label="Slide viewer"><pre id="error" role="alert" hidden></pre><div id="stage" tabindex="-1"><div id="empty">Waiting for slides…</div><div id="slide" hidden></div></div></main>
|
|
403
|
-
<aside id="chat" aria-
|
|
1009
|
+
<main aria-label="Slide viewer"><iframe id="editor-frame" title="Presentation editor"></iframe><pre id="error" role="alert" hidden></pre><div id="stage" tabindex="-1"><div id="empty">Waiting for slides…</div><div id="slide" hidden></div></div></main>
|
|
1010
|
+
<aside id="chat" aria-labelledby="agents-title">
|
|
404
1011
|
<div id="chat-resizer" role="separator" tabindex="0" aria-label="Chat width" aria-orientation="vertical" aria-controls="chat" title="Drag to resize · Double-click to reset"></div>
|
|
405
|
-
<div class="
|
|
1012
|
+
<div class="pane-head"><strong id="agents-title">Agents</strong><button id="close-chat" class="pane-close" aria-label="Close Agents" title="Close Agents">×</button></div><div id="agent-workspace"></div><div id="chat-context" hidden></div></aside>
|
|
406
1013
|
</div>
|
|
407
|
-
<footer><span id="count" aria-live="polite">No slides</span><span class="hint">Select an area to ask AI · Edit text directly</span><button id="prev" aria-label="Previous slide" disabled>‹</button><button id="next" aria-label="Next slide" disabled>›</button><label for="zoom">Zoom</label><select id="zoom"><option value="fit">Fit</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option></select></footer>
|
|
408
|
-
<div id="presentation-controls"><button id="present-prev" aria-label="Previous slide">‹</button><span id="present-count"></span><button id="present-next" aria-label="Next slide">›</button><button id="exit-present">Exit · Esc</button></div>
|
|
1014
|
+
<footer><span id="count" aria-live="polite">No slides</span><span id="status" role="status">Building…</span><span class="hint">Select an area to ask AI · Edit text directly</span><button id="prev" aria-label="Previous slide" disabled>‹</button><button id="next" aria-label="Next slide" disabled>›</button><button id="present" disabled>Present</button><button id="presenter" disabled>Presenter view</button><a class="download" href="/deck.pptx">Download PPTX</a><button id="toggle-chat" aria-pressed="false" aria-controls="chat"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 9.4 6.6 14.5 8 9.4 9.4 8 14.5 6.6 9.4 1.5 8 6.6 6.6Z" stroke-linejoin="round"/></svg><span>Agents</span></button><span class="status-views" role="group" aria-label="Presentation views"><button id="toggle-editor" aria-label="Normal" title="Normal (Esc)"><svg width="16" height="12" viewBox="0 0 16 12" aria-hidden="true"><rect x=".5" y=".5" width="15" height="11" rx="1"/><path d="M5 1v10"/></svg></button></span><label for="zoom">Zoom</label><select id="zoom"><option value="fit">Fit</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option></select></footer><div id="notice" role="alert" hidden></div>
|
|
1015
|
+
<div id="presentation-scrollbar" role="scrollbar" aria-label="Slide position" aria-controls="slide" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" tabindex="0" hidden><span></span></div><div id="presentation-controls"><button id="present-prev" aria-label="Previous slide">‹</button><span id="present-count"></span><span id="rehearsal-timer" role="timer" aria-label="Rehearsal time" hidden></span><span id="present-note" role="status" hidden></span><button id="animation-retry" hidden></button><button id="present-next" aria-label="Next slide">›</button><button id="exit-present">Exit · Esc</button></div>
|
|
409
1016
|
<script>
|
|
410
|
-
let state={slides:[],error:null,aspectRatio:16/9},index=0,urls=[],presenting=false;
|
|
1017
|
+
let state={slides:[],error:null,aspectRatio:16/9,showProperties:null,customShows:[]},index=0,urls=[],presenting=false;
|
|
1018
|
+
let showOrder=[],showCursor=0,lastViewed=null,linkedShowId=null;
|
|
1019
|
+
let showReturns=[];
|
|
1020
|
+
// The fullscreen request of the show now starting. Starting or ending any show
|
|
1021
|
+
// (Present, presenter view, Escape) drops it, so a grant arriving after that
|
|
1022
|
+
// belongs to no show and is released instead of adopted.
|
|
1023
|
+
let presentationFullscreen=false,fullscreenRequest=null;
|
|
1024
|
+
function loopShow(){return showReturns.length===0&&(state.showProperties?.loop||state.showProperties?.mode?.kind==='kiosk');}
|
|
411
1025
|
let displayedSvg;
|
|
1026
|
+
let presenterWindow;
|
|
1027
|
+
${previewI18nScript}
|
|
1028
|
+
function rebuildShowOrder(){
|
|
1029
|
+
const previousSlide=showOrder[showCursor];
|
|
1030
|
+
const settings=state.showProperties;
|
|
1031
|
+
let order;
|
|
1032
|
+
if(linkedShowId!==null){
|
|
1033
|
+
order=state.customShows?.find(show=>show.id===linkedShowId)?.slideIndices??[];
|
|
1034
|
+
}else if(settings?.slides?.kind==='range'){
|
|
1035
|
+
const start=Math.max(1,settings.slides.start),end=Math.min(state.slides.length,settings.slides.end);
|
|
1036
|
+
order=Array.from({length:Math.max(0,end-start+1)},(_,offset)=>start-1+offset);
|
|
1037
|
+
}else if(settings?.slides?.kind==='customShow'){
|
|
1038
|
+
order=state.customShows?.find(show=>show.id===settings.slides.id)?.slideIndices??[];
|
|
1039
|
+
}else order=Array.from({length:state.slides.length},(_,slide)=>slide);
|
|
1040
|
+
showOrder=order.filter(slide=>slide>=0&&slide<state.slides.length);
|
|
1041
|
+
if(previousSlide!==undefined){
|
|
1042
|
+
if(showOrder[showCursor]===previousSlide)return;
|
|
1043
|
+
const candidates=showOrder.map((slide,position)=>slide===previousSlide?position:-1).filter(position=>position>=0);
|
|
1044
|
+
if(candidates.length){showCursor=candidates.reduce((best,position)=>Math.abs(position-showCursor)<Math.abs(best-showCursor)?position:best,candidates[0]);return;}
|
|
1045
|
+
}
|
|
1046
|
+
const current=showOrder.indexOf(index);
|
|
1047
|
+
if(current>=0)showCursor=current;
|
|
1048
|
+
else showCursor=Math.min(showCursor,Math.max(0,showOrder.length-1));
|
|
1049
|
+
}
|
|
1050
|
+
function showSlideAt(position,step=1,skipHidden=presenting){
|
|
1051
|
+
if(!showOrder.length)return -1;
|
|
1052
|
+
let cursor=position;
|
|
1053
|
+
for(let n=0;n<showOrder.length;n++){
|
|
1054
|
+
if(cursor<0||cursor>=showOrder.length){
|
|
1055
|
+
if(loopShow())cursor=(cursor+showOrder.length)%showOrder.length;
|
|
1056
|
+
else return -1;
|
|
1057
|
+
}
|
|
1058
|
+
const candidate=showOrder[cursor];
|
|
1059
|
+
if(!skipHidden||!state.hiddenSlides?.[candidate])return candidate;
|
|
1060
|
+
cursor+=step;
|
|
1061
|
+
}
|
|
1062
|
+
return -1;
|
|
1063
|
+
}
|
|
1064
|
+
function firstShowSlide(){return showSlideAt(0,1,true);}
|
|
1065
|
+
function nextShowPosition(step){
|
|
1066
|
+
if(!showOrder.length)return -1;
|
|
1067
|
+
let cursor=showCursor+step;
|
|
1068
|
+
for(let n=0;n<showOrder.length;n++){
|
|
1069
|
+
if(cursor<0||cursor>=showOrder.length){
|
|
1070
|
+
if(loopShow())cursor=(cursor+showOrder.length)%showOrder.length;
|
|
1071
|
+
else return -1;
|
|
1072
|
+
}
|
|
1073
|
+
if(!presenting||!state.hiddenSlides?.[showOrder[cursor]])return cursor;
|
|
1074
|
+
cursor+=step;
|
|
1075
|
+
}
|
|
1076
|
+
return -1;
|
|
1077
|
+
}
|
|
1078
|
+
function nextShowSlide(step){
|
|
1079
|
+
const position=nextShowPosition(step);
|
|
1080
|
+
return position<0?-1:showOrder[position];
|
|
1081
|
+
}
|
|
1082
|
+
function browsePositions(){
|
|
1083
|
+
return showOrder.map((slide,position)=>({slide,position})).filter(({slide})=>!state.hiddenSlides?.[slide]);
|
|
1084
|
+
}
|
|
1085
|
+
function syncBrowseScrollbar(){
|
|
1086
|
+
const scrollbar=byId('presentation-scrollbar');
|
|
1087
|
+
const enabled=presenting&&state.showProperties?.mode?.kind==='browse'&&state.showProperties?.mode?.showScrollbar===true;
|
|
1088
|
+
scrollbar.setAttribute('aria-label',pt('Slide position'));
|
|
1089
|
+
scrollbar.hidden=!enabled;
|
|
1090
|
+
document.body.classList.toggle('browse-scrollbar',enabled);
|
|
1091
|
+
if(!enabled)return;
|
|
1092
|
+
const positions=browsePositions();
|
|
1093
|
+
const max=Math.max(0,positions.length-1);
|
|
1094
|
+
scrollbar.setAttribute('aria-valuemax',String(max));
|
|
1095
|
+
const current=positions.findIndex(({position})=>position===showCursor);
|
|
1096
|
+
const value=Math.max(0,current);
|
|
1097
|
+
scrollbar.setAttribute('aria-valuenow',String(value));
|
|
1098
|
+
const thumbPercent=positions.length?Math.max(8,100/positions.length):100;
|
|
1099
|
+
scrollbar.firstElementChild.style.height=thumbPercent+'%';
|
|
1100
|
+
scrollbar.firstElementChild.style.top=(max?(value/max)*(100-thumbPercent):0)+'%';
|
|
1101
|
+
scrollbar.setAttribute('aria-valuetext',positions.length?slideLabel(positions[value].slide):pt('No slides'));
|
|
1102
|
+
}
|
|
1103
|
+
function findSlide(start,step,skipHidden=presenting){
|
|
1104
|
+
if(skipHidden&&presenting)return showSlideAt(showCursor+step,step);
|
|
1105
|
+
for(let i=start;i>=0&&i<state.slides.length;i+=step)if(!skipHidden||!state.hiddenSlides?.[i])return i;
|
|
1106
|
+
return -1;
|
|
1107
|
+
}
|
|
1108
|
+
function moveSlide(step,skipHidden=presenting,focusThumbnail=false){
|
|
1109
|
+
// The slide has effects and the player is still on its way: moving on now
|
|
1110
|
+
// would skip them, so the click waits rather than costing the viewer content.
|
|
1111
|
+
if(animationsWaiting())return;
|
|
1112
|
+
// A build comes before the slide: the click that would move on first plays
|
|
1113
|
+
// whatever the current slide still has to show, in either direction.
|
|
1114
|
+
if(animationPlayer&&(step>0?animationPlayer.advance():animationPlayer.back()))return;
|
|
1115
|
+
const previousShowCursor=showCursor;
|
|
1116
|
+
const nextPosition=skipHidden&&presenting?nextShowPosition(step):-1;
|
|
1117
|
+
const next=skipHidden&&presenting?(nextPosition<0?-1:showOrder[nextPosition]):findSlide(index+step,step,skipHidden);
|
|
1118
|
+
// Arriving backwards lands on a slide that has already played out, the way
|
|
1119
|
+
// PowerPoint shows it; arriving forwards starts its build from the top.
|
|
1120
|
+
if(next>=0){
|
|
1121
|
+
selectSlide(next,focusThumbnail,true,step<0?'end':'start',nextPosition);
|
|
1122
|
+
if(presenting&&step>0&&nextPosition>=0&&nextPosition<=previousShowCursor&&loopShow())scheduleKioskRestart();
|
|
1123
|
+
}
|
|
1124
|
+
else if(presenting&&step>0&&linkedShowId!==null)void exitPresentation();
|
|
1125
|
+
}
|
|
1126
|
+
let advanceTimer,advanceKey;
|
|
1127
|
+
let kioskRestartTimer,kioskRestartKey;
|
|
1128
|
+
function clearKioskRestart(){
|
|
1129
|
+
clearTimeout(kioskRestartTimer);kioskRestartTimer=undefined;kioskRestartKey=null;
|
|
1130
|
+
}
|
|
1131
|
+
function scheduleKioskRestart(){
|
|
1132
|
+
clearKioskRestart();
|
|
1133
|
+
const restart=state.showProperties?.mode?.kind==='kiosk'?Number(state.showProperties.mode.restart):NaN;
|
|
1134
|
+
// The OOXML attribute is an unsigned duration, but the schema does not
|
|
1135
|
+
// define a special meaning for zero. Do not turn an unconfigured/zero value
|
|
1136
|
+
// into a busy immediate restart loop; only a positive duration schedules it.
|
|
1137
|
+
if(!presenting||!Number.isFinite(restart)||restart<=0)return;
|
|
1138
|
+
const key={};kioskRestartKey=key;
|
|
1139
|
+
const deadline=performance.now()+restart;
|
|
1140
|
+
function tick(){
|
|
1141
|
+
if(kioskRestartKey!==key||!presenting)return;
|
|
1142
|
+
const remaining=deadline-performance.now();
|
|
1143
|
+
if(remaining>0){kioskRestartTimer=setTimeout(tick,Math.min(remaining,2147483647));return;}
|
|
1144
|
+
const first=firstShowSlide();
|
|
1145
|
+
if(first>=0){
|
|
1146
|
+
const position=showOrder.indexOf(first);
|
|
1147
|
+
clearTimeout(advanceTimer);advanceTimer=undefined;advanceKey=null;
|
|
1148
|
+
selectSlide(first,false,true,'start',position);
|
|
1149
|
+
scheduleKioskRestart();
|
|
1150
|
+
}else clearKioskRestart();
|
|
1151
|
+
}
|
|
1152
|
+
kioskRestartTimer=setTimeout(tick,Math.min(restart,2147483647));
|
|
1153
|
+
}
|
|
1154
|
+
function scheduleAdvance(){
|
|
1155
|
+
const delay=state.transitions?.[index]?.advanceAfterMs;
|
|
1156
|
+
// A slide that still has effects to play — or one whose last click is still
|
|
1157
|
+
// playing out — is not finished, whatever its transition says about
|
|
1158
|
+
// advancing itself.
|
|
1159
|
+
const settled=!animationsWaiting()&&(!animationPlayer||!(animationPlayer.pending||animationPlayer.running));
|
|
1160
|
+
const key=presenting&&state.showProperties?.useTimings!==false&&settled&&(nextShowSlide(1)>=0||linkedShowId!==null)&&Number.isFinite(delay)&&delay>=0?index+':'+showCursor+':'+delay:null;
|
|
1161
|
+
if(key===advanceKey)return;
|
|
1162
|
+
clearTimeout(advanceTimer);advanceKey=key;
|
|
1163
|
+
if(key===null)return;
|
|
1164
|
+
// OOXML unsigned milliseconds can exceed the browser's signed timer limit.
|
|
1165
|
+
const deadline=performance.now()+delay;
|
|
1166
|
+
function tick(){
|
|
1167
|
+
const remaining=deadline-performance.now();
|
|
1168
|
+
if(remaining>0)advanceTimer=setTimeout(tick,Math.min(remaining,2147483647));
|
|
1169
|
+
else {advanceKey=null;moveSlide(1);}
|
|
1170
|
+
}
|
|
1171
|
+
advanceTimer=setTimeout(tick,Math.min(delay,2147483647));
|
|
1172
|
+
}
|
|
412
1173
|
const byId=id=>document.getElementById(id);
|
|
1174
|
+
// Next and previous move through the slide's own build before they move
|
|
1175
|
+
// through the deck, so the last slide's remaining effects are still reachable
|
|
1176
|
+
// and the first step back is the one inside this slide.
|
|
1177
|
+
// True while this slide has effects but the player has not arrived yet. Its
|
|
1178
|
+
// clicks belong to the build, so they are held rather than spent on the deck.
|
|
1179
|
+
function animationsWaiting(){return state.showProperties?.showAnimation!==false&&animationLoad==='loading'&&presenting&&animationStepsAt(index).length>0;}
|
|
1180
|
+
function animationsPending(){return state.showProperties?.showAnimation!==false&&(animationsWaiting()||Boolean(animationPlayer&&animationPlayer.pending));}
|
|
1181
|
+
function animationsBehind(){return Boolean(animationPlayer&&animationPlayer.cursor>0);}
|
|
1182
|
+
function updateNavigationButtons(){
|
|
1183
|
+
for(const id of ['prev','present-prev'])byId(id).disabled=presenting?nextShowSlide(-1)<0&&!animationsBehind():findSlide(index-1,-1)<0&&!animationsBehind();
|
|
1184
|
+
for(const id of ['next','present-next'])byId(id).disabled=presenting?nextShowSlide(1)<0&&linkedShowId===null&&!animationsPending():findSlide(index+1,1)<0&&!animationsPending();
|
|
1185
|
+
byId('present').disabled=firstShowSlide()<0;
|
|
1186
|
+
byId('presenter').disabled=byId('present').disabled;
|
|
1187
|
+
}
|
|
413
1188
|
const stage=byId('stage'),slide=byId('slide'),thumbnails=byId('thumbnails');
|
|
414
1189
|
const workspace=document.querySelector('.workspace'),chatResizer=byId('chat-resizer');
|
|
415
1190
|
const chatWidthKey='office-kit-chat-width',minChatWidth=280,minStageWidth=240;
|
|
@@ -418,7 +1193,7 @@ function updateChatWidthAria(){
|
|
|
418
1193
|
const {min,max}=chatWidthLimits();
|
|
419
1194
|
chatResizer.setAttribute('aria-valuemin',String(min));chatResizer.setAttribute('aria-valuemax',String(Math.round(max)));
|
|
420
1195
|
const width=Math.round(byId('chat').getBoundingClientRect().width);
|
|
421
|
-
chatResizer.setAttribute('aria-valuenow',String(width));chatResizer.setAttribute('aria-valuetext',width+' pixels');
|
|
1196
|
+
chatResizer.setAttribute('aria-valuenow',String(width));chatResizer.setAttribute('aria-valuetext',width+(previewLocale==='ja'?' ピクセル':' pixels'));
|
|
422
1197
|
}
|
|
423
1198
|
function setChatWidth(width){const {min,max}=chatWidthLimits();workspace.style.setProperty('--chat-width',Math.min(max,Math.max(min,width))+'px');}
|
|
424
1199
|
function saveChatWidth(){try{const width=workspace.style.getPropertyValue('--chat-width');if(width)localStorage.setItem(chatWidthKey,String(parseFloat(width)));else localStorage.removeItem(chatWidthKey);}catch(error){console.warn('Could not save chat width',error);}}
|
|
@@ -446,6 +1221,153 @@ new ResizeObserver(updateChatWidthAria).observe(byId('chat'));
|
|
|
446
1221
|
// SVG stays out of the viewer's own DOM and CSS. Keyboard events still reach the
|
|
447
1222
|
// document, so arrow-key navigation keeps working after clicking into a slide.
|
|
448
1223
|
const canvas=slide.attachShadow({mode:'open'});
|
|
1224
|
+
${transitionScript}
|
|
1225
|
+
// The slide's object animations. One player per slide, kept while the slide and
|
|
1226
|
+
// its timing stay as they are: an unrelated rebuild (new speaker notes, say)
|
|
1227
|
+
// must not throw away how far the viewer has clicked through a build.
|
|
1228
|
+
//
|
|
1229
|
+
// The player is the editor's module, served as one build, so the show, the
|
|
1230
|
+
// presenter window and the editor panel cannot disagree about a timing tree.
|
|
1231
|
+
let animationPlayer,animationPlayerKey,createAnimationPlayer,pendingAnimationPosition;
|
|
1232
|
+
// 'loading' | 'ready' | 'failed'. A slide show holds its clicks while the
|
|
1233
|
+
// player is on its way; if it never arrives, the viewer is told so and can ask
|
|
1234
|
+
// for it again rather than watching a deck quietly skip its animations.
|
|
1235
|
+
let animationLoad='loading',animationLoadAttempt=0;
|
|
1236
|
+
function loadAnimationPlayer(){
|
|
1237
|
+
if(animationLoad==='ready')return;
|
|
1238
|
+
animationLoad='loading';updateAnimationNotice();
|
|
1239
|
+
// A module that failed to load stays failed in the browser's module map, so
|
|
1240
|
+
// asking again means asking for a different URL.
|
|
1241
|
+
const url='/animation-player.js'+(animationLoadAttempt++?'?retry='+animationLoadAttempt:'');
|
|
1242
|
+
import(url).then(module=>{
|
|
1243
|
+
createAnimationPlayer=module.createAnimationPlayer;animationLoad='ready';
|
|
1244
|
+
const position=pendingAnimationPosition;pendingAnimationPosition=undefined;
|
|
1245
|
+
if(position!==undefined)syncAnimationPlayer(position);
|
|
1246
|
+
updateNavigationButtons();updateAnimationNotice();scheduleAdvance();
|
|
1247
|
+
},error=>{
|
|
1248
|
+
animationLoad='failed';console.warn('Could not load the animation player',error);
|
|
1249
|
+
updateNavigationButtons();updateAnimationNotice();scheduleAdvance();
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
const animationStepsAt=i=>state.animations?.[i]??[];
|
|
1253
|
+
const animationKeyAt=i=>i+'\u0000'+(state.slides[i]??'')+'\u0000'+JSON.stringify(animationStepsAt(i));
|
|
1254
|
+
// A transition draws the arriving slide into a layer of its own, beside a layer
|
|
1255
|
+
// holding the slide being left — whose shapes carry ids of their own, from a
|
|
1256
|
+
// different slide's id space. Only the arriving one is this slide.
|
|
1257
|
+
const slideRoot=()=>canvas.querySelector('.transition-layer:not(.transition-old)')??canvas;
|
|
1258
|
+
let mediaPlayer,mediaPlayerKey,mediaPlayerConfigKey,createMediaPlayer,mediaLoading=false,mediaGeneration=0;
|
|
1259
|
+
const backgroundMedia=[];
|
|
1260
|
+
const backgroundMediaRoot=document.createElement('div');
|
|
1261
|
+
backgroundMediaRoot.hidden=true;
|
|
1262
|
+
stage.append(backgroundMediaRoot);
|
|
1263
|
+
const mediaRetry=document.createElement('button');
|
|
1264
|
+
mediaRetry.hidden=true;
|
|
1265
|
+
byId('presentation-controls').append(mediaRetry);
|
|
1266
|
+
mediaRetry.onclick=()=>syncMediaPlayer();
|
|
1267
|
+
function disposeCurrentMediaPlayer(){
|
|
1268
|
+
mediaPlayer?.dispose();mediaPlayer=undefined;mediaPlayerKey=undefined;mediaPlayerConfigKey=undefined;
|
|
1269
|
+
}
|
|
1270
|
+
function disposeMediaPlayer(){
|
|
1271
|
+
disposeCurrentMediaPlayer();
|
|
1272
|
+
for(const retained of backgroundMedia)retained.player.dispose();
|
|
1273
|
+
backgroundMedia.length=0;
|
|
1274
|
+
}
|
|
1275
|
+
function leaveMediaSlide(previousIndex,previousCursor){
|
|
1276
|
+
if(!presenting){disposeMediaPlayer();return;}
|
|
1277
|
+
if(mediaPlayer){backgroundMedia.push({player:mediaPlayer,index:previousIndex,cursor:previousCursor});mediaPlayer=undefined;mediaPlayerKey=undefined;mediaPlayerConfigKey=undefined;}
|
|
1278
|
+
for(let i=backgroundMedia.length-1;i>=0;i--){
|
|
1279
|
+
const retained=backgroundMedia[i];
|
|
1280
|
+
if(index===retained.index||!retained.player.retainAcrossSlides(showCursor-retained.cursor,backgroundMediaRoot)){
|
|
1281
|
+
retained.player.dispose();backgroundMedia.splice(i,1);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
function syncMediaPlayer(){
|
|
1286
|
+
const clips=(state.media??[]).filter(clip=>clip.slideIndex===index&&clip.kind!=='online');
|
|
1287
|
+
if(!presenting){disposeMediaPlayer();mediaRetry.hidden=true;return;}
|
|
1288
|
+
if(!clips.length){disposeCurrentMediaPlayer();mediaRetry.hidden=true;return;}
|
|
1289
|
+
if(!createMediaPlayer){
|
|
1290
|
+
if(mediaLoading)return;
|
|
1291
|
+
mediaLoading=true;mediaRetry.hidden=true;
|
|
1292
|
+
import('/media-player.js?attempt='+Date.now()).then(module=>{
|
|
1293
|
+
createMediaPlayer=module.createMediaPlayer;mediaLoading=false;syncMediaPlayer();
|
|
1294
|
+
},error=>{
|
|
1295
|
+
mediaLoading=false;console.warn('Could not load the media player',error);
|
|
1296
|
+
mediaRetry.textContent=previewLocale==='ja'?'メディアを読み込めませんでした · 再試行':'Media could not load · Retry';
|
|
1297
|
+
mediaRetry.hidden=!presenting;
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const key=JSON.stringify([index,clips]);
|
|
1302
|
+
if(mediaPlayerConfigKey===key)return;
|
|
1303
|
+
disposeCurrentMediaPlayer();
|
|
1304
|
+
mediaPlayer=createMediaPlayer({root:slideRoot(),overlayRoot:stage,clips,locale:previewLocale});
|
|
1305
|
+
mediaPlayerConfigKey=key;
|
|
1306
|
+
mediaPlayerKey=String(++mediaGeneration)+':'+key;
|
|
1307
|
+
updatePresenter();
|
|
1308
|
+
}
|
|
1309
|
+
function updateAnimationNotice(){
|
|
1310
|
+
const note=byId('present-note'),retry=byId('animation-retry');
|
|
1311
|
+
const slideHasEffects=presenting&&state.showProperties?.showAnimation!==false&&animationStepsAt(index).length>0;
|
|
1312
|
+
retry.hidden=!(slideHasEffects&&animationLoad==='failed');
|
|
1313
|
+
retry.textContent=previewLocale==='ja'?'再試行':'Try again';
|
|
1314
|
+
if(slideHasEffects&&animationLoad==='failed'){
|
|
1315
|
+
note.hidden=false;
|
|
1316
|
+
note.textContent=previewLocale==='ja'
|
|
1317
|
+
?'アニメーションを読み込めませんでした。このスライドの効果は再生されません。'
|
|
1318
|
+
:'The animations could not be loaded, so this slide’s effects are not played.';
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if(animationsWaiting()){
|
|
1322
|
+
note.hidden=false;
|
|
1323
|
+
note.textContent=previewLocale==='ja'?'アニメーションを準備中…':'Preparing animations…';
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const skipped=animationPlayer?animationPlayer.unsupported:[];
|
|
1327
|
+
note.hidden=skipped.length===0;
|
|
1328
|
+
note.textContent=skipped.length===0?'':previewLocale==='ja'
|
|
1329
|
+
?'このスライドの '+skipped.length+' 件のアニメーションは未対応のため再生されません'
|
|
1330
|
+
:skipped.length===1
|
|
1331
|
+
?'1 animation on this slide is not supported yet, so it is not played'
|
|
1332
|
+
:skipped.length+' animations on this slide are not supported yet, so they are not played';
|
|
1333
|
+
}
|
|
1334
|
+
function syncAnimationPlayer(position){
|
|
1335
|
+
// Animations belong to the slide show. The still preview shows the slide as
|
|
1336
|
+
// it ends, which is what the renderer drew.
|
|
1337
|
+
const wanted=presenting&&state.showProperties?.showAnimation!==false&&animationStepsAt(index).length>0;
|
|
1338
|
+
if(!wanted){
|
|
1339
|
+
if(animationPlayer){animationPlayer.dispose();animationPlayer=undefined;animationPlayerKey=undefined;}
|
|
1340
|
+
updateAnimationNotice();
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if(!createAnimationPlayer){
|
|
1344
|
+
// Still on its way, or it never arrived. The slide keeps the picture the
|
|
1345
|
+
// renderer drew; this position is applied the moment the player is there.
|
|
1346
|
+
pendingAnimationPosition=position;
|
|
1347
|
+
updateAnimationNotice();
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
const key=animationKeyAt(index);
|
|
1351
|
+
if(!animationPlayer||animationPlayerKey!==key){
|
|
1352
|
+
// A changed timing makes the old cursor meaningless — the effects it
|
|
1353
|
+
// counted are not the effects there are now.
|
|
1354
|
+
animationPlayer?.dispose();
|
|
1355
|
+
animationPlayer=createAnimationPlayer({
|
|
1356
|
+
root:slideRoot,
|
|
1357
|
+
steps:animationStepsAt(index),
|
|
1358
|
+
reducedMotion:()=>reducedMotion.matches,
|
|
1359
|
+
onChange:()=>{updateNavigationButtons();updatePresenter();scheduleAdvance();},
|
|
1360
|
+
});
|
|
1361
|
+
animationPlayerKey=key;
|
|
1362
|
+
position==='end'?animationPlayer.finish():animationPlayer.reset();
|
|
1363
|
+
updateAnimationNotice();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if(position==='end')animationPlayer.finish();
|
|
1367
|
+
else if(position==='start')animationPlayer.reset();
|
|
1368
|
+
// 'keep' leaves the cursor where the viewer left it.
|
|
1369
|
+
updateAnimationNotice();
|
|
1370
|
+
}
|
|
449
1371
|
function resize(){
|
|
450
1372
|
if(!state.slides.length)return;
|
|
451
1373
|
const style=getComputedStyle(stage);
|
|
@@ -456,36 +1378,56 @@ function resize(){
|
|
|
456
1378
|
const slideWidth=presenting||zoom==='fit'?Math.min(width,height*ratio):1280*Number(zoom);
|
|
457
1379
|
slide.style.width=slideWidth+'px';slide.style.height=slideWidth/ratio+'px';
|
|
458
1380
|
}
|
|
459
|
-
function selectSlide(next,focusThumbnail=false,reveal=true){
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
1381
|
+
function selectSlide(next,focusThumbnail=false,reveal=true,position='start',showPosition=-1){
|
|
1382
|
+
const previousIndex=index,previousCursor=showCursor;
|
|
1383
|
+
index=Math.max(0,Math.min(next,state.slides.length-1));
|
|
1384
|
+
if(showPosition>=0)showCursor=showPosition;
|
|
1385
|
+
else if(presenting){if(showOrder[showCursor]!==index){const naturalPosition=showOrder.indexOf(index);if(naturalPosition>=0)showCursor=naturalPosition;}}
|
|
1386
|
+
else {const naturalPosition=showOrder.indexOf(index);if(naturalPosition>=0)showCursor=naturalPosition;}
|
|
1387
|
+
if(presenting&&state.hiddenSlides?.[index]){
|
|
1388
|
+
const forwardPosition=nextShowPosition(1),backwardPosition=forwardPosition<0?nextShowPosition(-1):-1;
|
|
1389
|
+
const position=forwardPosition>=0?forwardPosition:backwardPosition;
|
|
1390
|
+
if(position>=0){showCursor=position;index=showOrder[position];}
|
|
1391
|
+
}
|
|
1392
|
+
if(presenting&&(previousIndex!==index||previousCursor!==showCursor))lastViewed={index:previousIndex,cursor:previousCursor};
|
|
1393
|
+
const count=slideCount();
|
|
1394
|
+
byId('chat-context').textContent=state.slides.length?count:pt('Whole project');
|
|
463
1395
|
byId('chat-context').dataset.focus=JSON.stringify({slide:state.slides.length?index:null,revision:state.revision??0});
|
|
464
1396
|
window.dispatchEvent(new Event('agent-focus'));
|
|
1397
|
+
if(document.body.classList.contains('editing')&&editorFocus)applyEditorFocus();
|
|
465
1398
|
byId('count').textContent=count;byId('present-count').textContent=count;
|
|
466
|
-
|
|
467
|
-
for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
|
|
468
|
-
byId('present').disabled=!state.slides.length;
|
|
1399
|
+
document.body.classList.toggle('kiosk-presenting',presenting&&state.showProperties?.mode?.kind==='kiosk');
|
|
469
1400
|
byId('zoom').disabled=!state.slides.length;
|
|
470
1401
|
slide.hidden=!state.slides.length;byId('empty').hidden=!!state.slides.length;
|
|
471
1402
|
const svg=state.slides[index];
|
|
472
|
-
if(
|
|
473
|
-
|
|
474
|
-
|
|
1403
|
+
if(previousIndex!==index||previousCursor!==showCursor)leaveMediaSlide(previousIndex,previousCursor);
|
|
1404
|
+
else if(svg!==displayedSvg||position!=='keep')disposeMediaPlayer();
|
|
1405
|
+
if(svg!==displayedSvg||previousIndex!==index){
|
|
1406
|
+
renderSlide(svg,presenting&&previousIndex!==index?state.transitions?.[index]:null);
|
|
475
1407
|
}
|
|
476
|
-
|
|
1408
|
+
syncAnimationPlayer(position);
|
|
1409
|
+
syncMediaPlayer();
|
|
1410
|
+
updateNavigationButtons();
|
|
1411
|
+
slide.setAttribute('aria-label',slideLabel(index));
|
|
477
1412
|
for(const [position,item] of Array.from(thumbnails.children).entries()){
|
|
478
1413
|
const button=item.firstElementChild,selected=position===index;
|
|
479
1414
|
button.setAttribute('aria-current',String(selected));button.tabIndex=selected?0:-1;
|
|
1415
|
+
button.dataset.skipped=String(!!state.hiddenSlides?.[position]);
|
|
1416
|
+
button.title=state.hiddenSlides?.[position]?pt('Skipped during presentation'):'';
|
|
480
1417
|
if(selected){if(reveal)button.scrollIntoView({block:'nearest'});if(focusThumbnail)button.focus({preventScroll:true});}
|
|
481
1418
|
}
|
|
1419
|
+
syncBrowseScrollbar();
|
|
482
1420
|
resize();
|
|
1421
|
+
if(!transitionCleanup)scheduleAdvance();
|
|
1422
|
+
updatePresenter();
|
|
483
1423
|
}
|
|
484
1424
|
function update(updated){
|
|
485
1425
|
const focusedThumbnail=thumbnails.contains(document.activeElement);
|
|
486
1426
|
const previous=state;
|
|
1427
|
+
if(JSON.stringify(previous.media)!==JSON.stringify(updated.media))disposeMediaPlayer();
|
|
487
1428
|
state=updated;
|
|
488
|
-
|
|
1429
|
+
rebuildShowOrder();
|
|
1430
|
+
connectionLost=false;updatePreviewStatus();
|
|
489
1431
|
byId('error').textContent=state.error||'';byId('error').hidden=!state.error;
|
|
490
1432
|
document.documentElement.style.setProperty('--slide-ratio',String(state.aspectRatio));
|
|
491
1433
|
for(let i=state.slides.length;i<urls.length;i++){
|
|
@@ -500,7 +1442,7 @@ function update(updated){
|
|
|
500
1442
|
if(!item){
|
|
501
1443
|
item=document.createElement('li');
|
|
502
1444
|
const button=document.createElement('button'),number=document.createElement('span'),image=document.createElement('img');
|
|
503
|
-
button.className='thumbnail';button.setAttribute('aria-label',
|
|
1445
|
+
button.className='thumbnail';button.setAttribute('aria-label',slideLabel(i));button.onclick=()=>selectSlide(i,true);
|
|
504
1446
|
number.className='slide-number';number.textContent=String(i+1);
|
|
505
1447
|
image.alt='';image.draggable=false;image.loading='lazy';
|
|
506
1448
|
button.append(number,image);item.append(button);thumbnails.append(item);
|
|
@@ -508,63 +1450,488 @@ function update(updated){
|
|
|
508
1450
|
item.querySelector('img').src=urls[i];
|
|
509
1451
|
if(oldUrl)URL.revokeObjectURL(oldUrl);
|
|
510
1452
|
});
|
|
511
|
-
|
|
512
|
-
|
|
1453
|
+
if(presenting&&firstShowSlide()<0)void exitPresentation();
|
|
1454
|
+
selectSlide(index,focusedThumbnail,false,'keep');
|
|
1455
|
+
if(presenting&&(previous.showProperties?.mode?.kind!==state.showProperties?.mode?.kind||previous.showProperties?.mode?.restart!==state.showProperties?.mode?.restart))scheduleKioskRestart();
|
|
513
1456
|
}
|
|
514
|
-
function setPresenting(value){
|
|
515
|
-
|
|
516
|
-
|
|
1457
|
+
function setPresenting(value,from){
|
|
1458
|
+
if(!value){presentationFullscreen=false;finishRehearsal();}
|
|
1459
|
+
fullscreenRequest=null;
|
|
1460
|
+
clearKioskRestart();
|
|
1461
|
+
linkedShowId=null;showReturns=[];
|
|
1462
|
+
lastViewed=null;
|
|
1463
|
+
cancelTransition();
|
|
1464
|
+
rebuildShowOrder();
|
|
1465
|
+
presenting=value&&firstShowSlide()>=0;document.body.classList.toggle('presenting',presenting);
|
|
1466
|
+
if(presenting){const first=from??firstShowSlide();if(first>=0){index=first;showCursor=showOrder.findIndex(slide=>slide===first);}}
|
|
1467
|
+
selectSlide(index);
|
|
1468
|
+
if(presenting)scheduleKioskRestart();
|
|
1469
|
+
if(presenting)stage.focus();else{
|
|
517
1470
|
byId('present').focus();
|
|
518
1471
|
thumbnails.children[index]?.firstElementChild.scrollIntoView({block:'nearest'});
|
|
519
1472
|
}
|
|
520
1473
|
}
|
|
1474
|
+
function returnToLastViewed(){
|
|
1475
|
+
if(!presenting||!lastViewed||lastViewed.index>=state.slides.length)return;
|
|
1476
|
+
const target=lastViewed;
|
|
1477
|
+
selectSlide(target.index,false,true,'start',showOrder[target.cursor]===target.index?target.cursor:-1);
|
|
1478
|
+
}
|
|
1479
|
+
function followNavigationLink(href){
|
|
1480
|
+
const directions={'#pptx-next-slide':1,'#pptx-prev-slide':-1,'#pptx-first-slide':0,'#pptx-last-slide':0};
|
|
1481
|
+
if(!Object.hasOwn(directions,href))return false;
|
|
1482
|
+
const step=directions[href];
|
|
1483
|
+
let position=-1,target=-1;
|
|
1484
|
+
if(step){
|
|
1485
|
+
position=presenting?nextShowPosition(step):-1;
|
|
1486
|
+
target=presenting?(position>=0?showOrder[position]:-1):findSlide(index+step,step,true);
|
|
1487
|
+
}else{
|
|
1488
|
+
const first=href==='#pptx-first-slide';
|
|
1489
|
+
target=presenting?showSlideAt(first?0:showOrder.length-1,first?1:-1):findSlide(first?0:state.slides.length-1,first?1:-1,true);
|
|
1490
|
+
if(presenting)position=first?showOrder.indexOf(target):showOrder.lastIndexOf(target);
|
|
1491
|
+
}
|
|
1492
|
+
if(target>=0)selectSlide(target,false,true,'start',position);
|
|
1493
|
+
else if(presenting&&step>0)void exitPresentation();
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
function launchCustomShow(id,returnToShow){
|
|
1497
|
+
if(!presenting)return;
|
|
1498
|
+
const show=state.customShows?.find(show=>show.id===id);
|
|
1499
|
+
const first=show?.slideIndices.find(slide=>slide>=0&&slide<state.slides.length&&!state.hiddenSlides?.[slide]);
|
|
1500
|
+
if(first===undefined)return;
|
|
1501
|
+
const caller={id:linkedShowId,index,cursor:showCursor,lastViewed,animation:animationPlayer?.progress};
|
|
1502
|
+
if(returnToShow)showReturns.push(caller);else showReturns=[];
|
|
1503
|
+
linkedShowId=id;showCursor=0;lastViewed=null;
|
|
1504
|
+
rebuildShowOrder();
|
|
1505
|
+
selectSlide(first,false,true,'start',showOrder.indexOf(first));
|
|
1506
|
+
lastViewed=null;
|
|
1507
|
+
}
|
|
1508
|
+
function followCustomShowLink(href){
|
|
1509
|
+
if(!href.startsWith('#pptx-custom-show?'))return false;
|
|
1510
|
+
const params=new URLSearchParams(href.slice('#pptx-custom-show?'.length));
|
|
1511
|
+
const id=params.get('id');
|
|
1512
|
+
if(id!==null&&/^[0-9]+$/.test(id))launchCustomShow(Number(id),params.get('return')==='true');
|
|
1513
|
+
return true;
|
|
1514
|
+
}
|
|
1515
|
+
// Slide Show ▸ Rehearse Timings: time each slide while presenting, then hand
|
|
1516
|
+
// the times to the editor, which asks whether to keep them as slide timings.
|
|
1517
|
+
let rehearsal=null;
|
|
1518
|
+
const clock=ms=>{const total=Math.floor(ms/1000),two=n=>String(n).padStart(2,'0');return Math.floor(total/3600)+':'+two(Math.floor(total/60)%60)+':'+two(total%60);};
|
|
1519
|
+
function rehearsalTick(){
|
|
1520
|
+
if(!rehearsal)return;
|
|
1521
|
+
const now=performance.now();
|
|
1522
|
+
// The show's cursor names the slide on screen; a custom show can repeat one.
|
|
1523
|
+
const showing=showOrder[showCursor]??index;
|
|
1524
|
+
if(showing!==rehearsal.slide){rehearsal.times.set(rehearsal.slide,(rehearsal.times.get(rehearsal.slide)??0)+now-rehearsal.since);rehearsal.slide=showing;rehearsal.since=now;}
|
|
1525
|
+
byId('rehearsal-timer').textContent=clock(now-rehearsal.since)+' · '+clock(now-rehearsal.started);
|
|
1526
|
+
}
|
|
1527
|
+
function startRehearsal(){
|
|
1528
|
+
const now=performance.now();
|
|
1529
|
+
rehearsal={times:new Map(),slide:showOrder[showCursor]??index,since:now,started:now,timer:setInterval(rehearsalTick,200)};
|
|
1530
|
+
byId('rehearsal-timer').hidden=false;rehearsalTick();
|
|
1531
|
+
}
|
|
1532
|
+
function finishRehearsal(){
|
|
1533
|
+
if(!rehearsal)return;
|
|
1534
|
+
rehearsalTick();
|
|
1535
|
+
const now=performance.now();
|
|
1536
|
+
rehearsal.times.set(rehearsal.slide,(rehearsal.times.get(rehearsal.slide)??0)+now-rehearsal.since);
|
|
1537
|
+
clearInterval(rehearsal.timer);byId('rehearsal-timer').hidden=true;
|
|
1538
|
+
const timings=[...rehearsal.times].map(([slide,ms])=>({slide,ms:Math.round(ms)}));
|
|
1539
|
+
rehearsal=null;
|
|
1540
|
+
editorFrame.contentWindow?.postMessage({type:'rehearsal-timings',timings},location.origin);
|
|
1541
|
+
}
|
|
521
1542
|
async function exitPresentation(){
|
|
1543
|
+
if(presenting&&showReturns.length){
|
|
1544
|
+
const caller=showReturns.pop();
|
|
1545
|
+
linkedShowId=caller.id;showCursor=caller.cursor;
|
|
1546
|
+
rebuildShowOrder();
|
|
1547
|
+
selectSlide(caller.index,false,true,'start',caller.cursor);
|
|
1548
|
+
lastViewed=caller.lastViewed;
|
|
1549
|
+
if(caller.animation&&animationPlayer)animationPlayer.resume(caller.animation.cursor,caller.animation.elapsed);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
522
1552
|
setPresenting(false);
|
|
523
1553
|
if(document.fullscreenElement)await document.exitFullscreen();
|
|
524
1554
|
}
|
|
525
|
-
|
|
526
|
-
setPresenting(true);
|
|
1555
|
+
async function startPresentation(from){
|
|
1556
|
+
setPresenting(true,from);
|
|
1557
|
+
if(state.showProperties?.mode?.kind==='browse')return;
|
|
1558
|
+
const request=fullscreenRequest={};
|
|
527
1559
|
try{await document.documentElement.requestFullscreen();}
|
|
528
|
-
catch{byId('exit-present').textContent='Exit view · Esc';}
|
|
1560
|
+
catch{if(fullscreenRequest===request){fullscreenRequest=null;byId('exit-present').textContent=pt('Exit view · Esc');}}
|
|
1561
|
+
}
|
|
1562
|
+
byId('present').onclick=()=>startPresentation();
|
|
1563
|
+
// Messages that used to sit in the shell's header now float over whichever
|
|
1564
|
+
// view is showing, so they are not lost while the editor fills the window.
|
|
1565
|
+
const NOTICE_MS=6000;
|
|
1566
|
+
let noticeTimer;
|
|
1567
|
+
function showNotice(text){const notice=byId('notice');notice.textContent=text;notice.hidden=false;clearTimeout(noticeTimer);noticeTimer=setTimeout(()=>{notice.hidden=true;},NOTICE_MS);}
|
|
1568
|
+
function updatePresenter(){
|
|
1569
|
+
if(!presenterWindow||presenterWindow.closed)return;
|
|
1570
|
+
// The presenter runs the same player over its own copy of the slide, from the
|
|
1571
|
+
// same cursor and the same moment within the stop in hand, so what it shows
|
|
1572
|
+
// is what the audience sees — including an effect still fading in.
|
|
1573
|
+
const progress=animationPlayer?animationPlayer.progress:null;
|
|
1574
|
+
const animationSteps=animationPlayer?animationStepsAt(index):null;
|
|
1575
|
+
presenterWindow.postMessage({type:'presenter-state',index,count:state.slides.length,animationSteps,current:state.slides[index]??null,next:state.slides[nextShowSlide(1)]??null,hasPrevious:nextShowSlide(-1)>=0||animationsBehind(),hasNext:nextShowSlide(1)>=0||linkedShowId!==null||animationsPending(),animation:progress,notes:state.notes?.[index]??'',aspectRatio:state.aspectRatio,locale:previewLocale,presenting,media:presenting?(state.media??[]).filter(clip=>clip.slideIndex===index):[],mediaKey:mediaPlayerKey,mediaProgress:mediaPlayer?.progress??[]},location.origin);
|
|
1576
|
+
}
|
|
1577
|
+
setInterval(()=>{
|
|
1578
|
+
if(!presenterWindow||presenterWindow.closed||!mediaPlayer)return;
|
|
1579
|
+
presenterWindow.postMessage({type:'presenter-media-state',index,mediaKey:mediaPlayerKey,mediaProgress:mediaPlayer.progress},location.origin);
|
|
1580
|
+
},250);
|
|
1581
|
+
byId('presenter').onclick=()=>{
|
|
1582
|
+
if(presenterWindow&&!presenterWindow.closed){setPresenting(true);presenterWindow.focus();return;}
|
|
1583
|
+
presenterWindow=window.open('/presenter','office-kit-presenter','popup,width=1100,height=800');
|
|
1584
|
+
if(presenterWindow)setPresenting(true);
|
|
1585
|
+
else showNotice(pt('Allow popups to open presenter view.'));
|
|
529
1586
|
};
|
|
1587
|
+
window.addEventListener('message',event=>{
|
|
1588
|
+
if(event.origin!==location.origin||event.source!==presenterWindow||event.data?.type!=='presenter-command')return;
|
|
1589
|
+
if(event.data.action==='ready')updatePresenter();
|
|
1590
|
+
else if(event.data.action==='media'){
|
|
1591
|
+
const command=event.data.index;
|
|
1592
|
+
if(!presenting||!mediaPlayer||event.data.mediaKey!==mediaPlayerKey||!command||!Number.isInteger(command.shapeId)||!['play','pause','seek'].includes(command.action))return;
|
|
1593
|
+
if(command.action==='seek'&&(!Number.isFinite(command.time)||command.time<0))return;
|
|
1594
|
+
mediaPlayer.command(command);updatePresenter();
|
|
1595
|
+
}
|
|
1596
|
+
else if(event.data.action==='next')moveSlide(1,true);
|
|
1597
|
+
else if(event.data.action==='previous')moveSlide(-1,true);
|
|
1598
|
+
else if(event.data.action==='exit')void exitPresentation();
|
|
1599
|
+
else if(event.data.action==='lastViewed')returnToLastViewed();
|
|
1600
|
+
else if(event.data.action==='navigation'&&typeof event.data.index==='string')followNavigationLink(event.data.index);
|
|
1601
|
+
else if(event.data.action==='customShow'&&typeof event.data.index==='string')followCustomShowLink(event.data.index);
|
|
1602
|
+
else if(event.data.action==='jump'&&Number.isInteger(event.data.index)&&event.data.index>=0&&event.data.index<state.slides.length)selectSlide(event.data.index);
|
|
1603
|
+
});
|
|
530
1604
|
byId('exit-present').onclick=exitPresentation;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1605
|
+
function scrollToBrowsePosition(value){
|
|
1606
|
+
const positions=browsePositions();
|
|
1607
|
+
const position=positions[Math.max(0,Math.min(positions.length-1,value))];
|
|
1608
|
+
if(position&&position.position!==showCursor)selectSlide(position.slide,false,true,'start',position.position);
|
|
1609
|
+
}
|
|
1610
|
+
const browseScrollbar=byId('presentation-scrollbar');
|
|
1611
|
+
let scrollbarDragOffset;
|
|
1612
|
+
function dragBrowseScrollbar(event){
|
|
1613
|
+
const rect=browseScrollbar.getBoundingClientRect();
|
|
1614
|
+
const thumb=browseScrollbar.firstElementChild.getBoundingClientRect();
|
|
1615
|
+
const travel=rect.height-thumb.height;
|
|
1616
|
+
if(travel<=0)return;
|
|
1617
|
+
const max=Number(browseScrollbar.getAttribute('aria-valuemax'));
|
|
1618
|
+
scrollToBrowsePosition(Math.round(((event.clientY-rect.top-scrollbarDragOffset)/travel)*max));
|
|
1619
|
+
}
|
|
1620
|
+
browseScrollbar.onpointerdown=event=>{
|
|
1621
|
+
if(event.button!==0)return;
|
|
1622
|
+
event.preventDefault();browseScrollbar.focus();
|
|
1623
|
+
const thumb=browseScrollbar.firstElementChild.getBoundingClientRect();
|
|
1624
|
+
scrollbarDragOffset=event.clientY>=thumb.top&&event.clientY<=thumb.bottom?event.clientY-thumb.top:thumb.height/2;
|
|
1625
|
+
browseScrollbar.setPointerCapture(event.pointerId);
|
|
1626
|
+
dragBrowseScrollbar(event);
|
|
1627
|
+
};
|
|
1628
|
+
browseScrollbar.onpointermove=event=>{
|
|
1629
|
+
if(browseScrollbar.hasPointerCapture(event.pointerId))dragBrowseScrollbar(event);
|
|
1630
|
+
};
|
|
1631
|
+
browseScrollbar.onpointerup=event=>{
|
|
1632
|
+
if(browseScrollbar.hasPointerCapture(event.pointerId))browseScrollbar.releasePointerCapture(event.pointerId);
|
|
1633
|
+
};
|
|
1634
|
+
browseScrollbar.onkeydown=event=>{
|
|
1635
|
+
const current=Number(browseScrollbar.getAttribute('aria-valuenow'));
|
|
1636
|
+
const max=Number(browseScrollbar.getAttribute('aria-valuemax'));
|
|
1637
|
+
let next;
|
|
1638
|
+
if(event.key==='ArrowDown'||event.key==='PageDown')next=current+1;
|
|
1639
|
+
else if(event.key==='ArrowUp'||event.key==='PageUp')next=current-1;
|
|
1640
|
+
else if(event.key==='Home')next=0;
|
|
1641
|
+
else if(event.key==='End')next=max;
|
|
1642
|
+
else return;
|
|
1643
|
+
event.preventDefault();event.stopPropagation();scrollToBrowsePosition(next);
|
|
1644
|
+
};
|
|
1645
|
+
byId('animation-retry').onclick=()=>{loadAnimationPlayer();};
|
|
1646
|
+
loadAnimationPlayer();
|
|
1647
|
+
document.addEventListener('fullscreenchange',()=>{
|
|
1648
|
+
// Only the page itself is ever requested here. A native video control can put
|
|
1649
|
+
// its <video> into fullscreen (and return to the page) on its own; that is the
|
|
1650
|
+
// viewer's choice, not a stale grant.
|
|
1651
|
+
if(document.fullscreenElement){
|
|
1652
|
+
if(document.fullscreenElement!==document.documentElement||presentationFullscreen)return;
|
|
1653
|
+
if(fullscreenRequest){fullscreenRequest=null;presentationFullscreen=true;}
|
|
1654
|
+
else void document.exitFullscreen();
|
|
1655
|
+
}
|
|
1656
|
+
else if(presentationFullscreen){presentationFullscreen=false;if(presenting)setPresenting(false);}
|
|
1657
|
+
});
|
|
1658
|
+
for(const id of ['prev','present-prev'])byId(id).onclick=()=>{if(!presenting||state.showProperties?.mode?.kind!=='kiosk')moveSlide(-1);};
|
|
1659
|
+
for(const id of ['next','present-next'])byId(id).onclick=()=>{if(!presenting||state.showProperties?.mode?.kind!=='kiosk')moveSlide(1);};
|
|
534
1660
|
byId('zoom').onchange=resize;
|
|
535
|
-
stage.onclick=
|
|
1661
|
+
stage.onclick=event=>{
|
|
1662
|
+
const link=event.composedPath().find(node=>node instanceof Element&&node.localName==='a');
|
|
1663
|
+
if(link){
|
|
1664
|
+
const href=link.getAttribute('href')??link.getAttributeNS('http://www.w3.org/1999/xlink','href')??'';
|
|
1665
|
+
if(followNavigationLink(href)||followCustomShowLink(href)){event.preventDefault();return;}
|
|
1666
|
+
if(href==='#pptx-end-show'||href==='#pptx-last-slide-viewed'){
|
|
1667
|
+
event.preventDefault();
|
|
1668
|
+
if(!presenting)return;
|
|
1669
|
+
if(href==='#pptx-end-show'){void exitPresentation();return;}
|
|
1670
|
+
returnToLastViewed();
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if(href.startsWith('#slide-')){
|
|
1674
|
+
event.preventDefault();
|
|
1675
|
+
const number=Number(href.slice(7));
|
|
1676
|
+
if(Number.isInteger(number)&&number>=1&&number<=state.slides.length)selectSlide(number-1);
|
|
1677
|
+
}
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
if(!presenting||state.showProperties?.mode?.kind==='kiosk'||getSelection().toString())return;
|
|
1681
|
+
// 'advClick' says whether a click moves to the next *slide*. A build still
|
|
1682
|
+
// belongs to this one, so its remaining effects play either way.
|
|
1683
|
+
if(animationsPending()||state.transitions?.[index]?.advanceOnClick!==false)moveSlide(1);
|
|
1684
|
+
};
|
|
536
1685
|
document.addEventListener('keydown',event=>{
|
|
537
1686
|
if(event.key==='Escape'&&presenting){event.preventDefault();void exitPresentation();return;}
|
|
1687
|
+
// As in PowerPoint's Reading View, Esc goes back to Normal view, unless it
|
|
1688
|
+
// is closing the viewer's own text field or area selection first.
|
|
1689
|
+
if(event.key==='Escape'&&!document.body.classList.contains('editing')&&!event.target.closest('select,input,textarea,[contenteditable]')&&document.querySelector('.slide-edit-panel')?.hidden!==false){event.preventDefault();chooseView(true);return;}
|
|
1690
|
+
if(presenting&&state.showProperties?.mode?.kind==='kiosk')return;
|
|
538
1691
|
if(event.altKey||event.ctrlKey||event.metaKey||event.target.closest('select,input,textarea,[contenteditable]'))return;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
else if(event.key
|
|
543
|
-
else if(event.key==='
|
|
544
|
-
|
|
1692
|
+
const focusThumbnail=thumbnails.contains(document.activeElement);
|
|
1693
|
+
let step=0,jump=-1,jumpPosition=-1;
|
|
1694
|
+
if(['ArrowLeft','ArrowUp','PageUp'].includes(event.key))step=-1;
|
|
1695
|
+
else if(['ArrowRight','ArrowDown','PageDown'].includes(event.key))step=1;
|
|
1696
|
+
else if(event.key==='Home'){
|
|
1697
|
+
jump=presenting?firstShowSlide():findSlide(0,1);
|
|
1698
|
+
if(presenting)jumpPosition=showOrder.findIndex(slide=>slide===jump&&!state.hiddenSlides?.[slide]);
|
|
1699
|
+
}
|
|
1700
|
+
else if(event.key==='End'){
|
|
1701
|
+
jump=presenting?showSlideAt(showOrder.length-1,-1):findSlide(state.slides.length-1,-1);
|
|
1702
|
+
if(presenting)jumpPosition=showOrder.findLastIndex(slide=>slide===jump&&!state.hiddenSlides?.[slide]);
|
|
1703
|
+
}
|
|
1704
|
+
else if(event.key===' '&&presenting&&!event.target.closest('button,a'))step=event.shiftKey?-1:1;
|
|
545
1705
|
else return;
|
|
546
|
-
event.preventDefault();
|
|
1706
|
+
event.preventDefault();
|
|
1707
|
+
// Home and End are a jump, not a step: they land on a slide with its build
|
|
1708
|
+
// at the start rather than walking through the one in hand.
|
|
1709
|
+
if(step!==0)moveSlide(step,presenting,focusThumbnail);
|
|
1710
|
+
else if(jump>=0)selectSlide(jump,focusThumbnail,true,'start',jumpPosition);
|
|
547
1711
|
});
|
|
548
1712
|
new ResizeObserver(resize).observe(stage);
|
|
549
1713
|
let refreshId=0;
|
|
550
1714
|
async function refresh(){
|
|
551
1715
|
const id=++refreshId;
|
|
552
1716
|
try{const response=await fetch('/state'+(state.revision===undefined?'':'?since='+state.revision));if(!response.ok)throw new Error('Preview unavailable');const updated=await response.json();if(id===refreshId){if(updated.changes){updated.slides=state.slides.slice(0,updated.count);updated.slides.length=updated.count;for(const [position,svg] of Object.entries(updated.changes))updated.slides[Number(position)]=svg;}update(updated);}}
|
|
553
|
-
catch{if(id===refreshId)
|
|
1717
|
+
catch{if(id===refreshId){connectionLost=true;updatePreviewStatus();}}
|
|
1718
|
+
}
|
|
1719
|
+
const editorFrame=byId('editor-frame');
|
|
1720
|
+
let editorFocus;
|
|
1721
|
+
// The Agents task pane opens on demand, like PowerPoint's panes; the editor's
|
|
1722
|
+
// Agents button (in its tab row) mirrors that state, so every change is reported back.
|
|
1723
|
+
const agentsKey='office-kit-agents-open';
|
|
1724
|
+
function agentsOpen(){return !document.body.classList.contains('chat-hidden');}
|
|
1725
|
+
function postAgentsState(focusButton=false){editorFrame.contentWindow?.postMessage({type:'host-panes',agents:agentsOpen(),focus:focusButton},location.origin);}
|
|
1726
|
+
function setAgentsOpen(open,{focusPane=false,returnFocus=false}={}){
|
|
1727
|
+
document.body.classList.toggle('chat-hidden',!open);
|
|
1728
|
+
byId('toggle-chat').setAttribute('aria-pressed',String(open));
|
|
1729
|
+
try{sessionStorage.setItem(agentsKey,open?'1':'0');}catch(error){console.warn('Could not save the Agents pane state',error);}
|
|
1730
|
+
postAgentsState(returnFocus&&document.body.classList.contains('editing'));
|
|
1731
|
+
resize();
|
|
1732
|
+
if(open&&focusPane)byId('close-chat').focus();
|
|
1733
|
+
else if(returnFocus){if(document.body.classList.contains('editing'))editorFrame.focus();else byId('toggle-chat').focus();}
|
|
554
1734
|
}
|
|
555
|
-
byId('toggle-chat').onclick=()=>
|
|
556
|
-
|
|
1735
|
+
byId('toggle-chat').onclick=()=>setAgentsOpen(!agentsOpen());
|
|
1736
|
+
byId('close-chat').onclick=()=>setAgentsOpen(false,{returnFocus:true});
|
|
1737
|
+
try{if(sessionStorage.getItem(agentsKey)==='1')setAgentsOpen(true);}catch(error){console.warn('Could not restore the Agents pane state',error);}
|
|
1738
|
+
function setEditorMode(editing){
|
|
1739
|
+
document.body.classList.toggle('editing',editing);
|
|
1740
|
+
if(editing&&!editorFrame.getAttribute('src'))editorFrame.src='/editor';
|
|
1741
|
+
if(editing&&editorFocus)applyEditorFocus();else selectSlide(index);
|
|
1742
|
+
resize();
|
|
1743
|
+
}
|
|
1744
|
+
// The rendered viewer is the editor's Reading View; Normal (or Esc) returns.
|
|
1745
|
+
function chooseView(editing){
|
|
1746
|
+
setEditorMode(editing);
|
|
1747
|
+
try{sessionStorage.setItem('office-kit-view',editing?'editor':'preview');}catch(error){console.warn('Could not save view preference',error);}
|
|
1748
|
+
if(editing)editorFrame.focus();else stage.focus();
|
|
1749
|
+
}
|
|
1750
|
+
byId('toggle-editor').onclick=()=>chooseView(true);
|
|
1751
|
+
try{setEditorMode(sessionStorage.getItem('office-kit-view')!=='preview');}catch(error){console.warn('Could not restore view preference',error);setEditorMode(true);}
|
|
1752
|
+
function applyEditorFocus(){
|
|
1753
|
+
if(!editorFocus)return;
|
|
1754
|
+
index=Math.max(0,editorFocus.slide);
|
|
1755
|
+
byId('chat-context').textContent=(editorFocus.locale==='ja'?'スライド ':'Slide ')+(index+1)+' / '+editorFocus.count+(editorFocus.dirty?' · '+(editorFocus.locale==='ja'?'未保存':'Unsaved'):'');
|
|
1756
|
+
byId('chat-context').dataset.focus=JSON.stringify({slide:editorFocus.count?index:null,revision:editorFocus.dirty?-1:editorFocus.revision});
|
|
1757
|
+
window.dispatchEvent(new Event('agent-focus'));
|
|
1758
|
+
}
|
|
1759
|
+
window.addEventListener('message',event=>{
|
|
1760
|
+
if(event.origin!==location.origin||event.source!==editorFrame.contentWindow||event.data?.type!=='editor-command')return;
|
|
1761
|
+
const slide=event.data.slide;
|
|
1762
|
+
if(event.data.action==='agents'){setAgentsOpen(!agentsOpen(),{focusPane:true});return;}
|
|
1763
|
+
if(event.data.action==='reading'){
|
|
1764
|
+
if(Number.isInteger(slide)&&slide>=0&&slide<state.slides.length)index=slide;
|
|
1765
|
+
chooseView(false);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
// The editor's Slide Show tab; the click's user activation reaches this page,
|
|
1769
|
+
// so fullscreen and the presenter popup are still allowed.
|
|
1770
|
+
if(byId('present').disabled)return;
|
|
1771
|
+
if(event.data.action==='start')void startPresentation();
|
|
1772
|
+
else if(event.data.action==='current'&&Number.isInteger(slide)&&slide>=0&&slide<state.slides.length)void startPresentation(slide);
|
|
1773
|
+
else if(event.data.action==='presenter')byId('presenter').click();
|
|
1774
|
+
else if(event.data.action==='rehearse'){void startPresentation();if(presenting)startRehearsal();}
|
|
1775
|
+
});
|
|
1776
|
+
window.addEventListener('message',event=>{
|
|
1777
|
+
if(event.origin!==location.origin||event.source!==editorFrame.contentWindow||event.data?.type!=='editor-focus')return;
|
|
1778
|
+
editorFocus=event.data;
|
|
1779
|
+
if(previewLocale!==editorFocus.locale&&(editorFocus.locale==='ja'||editorFocus.locale==='en')){previewLocale=editorFocus.locale;updatePreviewLabels();}
|
|
1780
|
+
updatePresenter();
|
|
1781
|
+
if(document.body.classList.contains('editing'))applyEditorFocus();
|
|
1782
|
+
postAgentsState();
|
|
1783
|
+
});
|
|
1784
|
+
updatePreviewLabels();
|
|
1785
|
+
const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='history'){window.dispatchEvent(new Event('agent-history'));return;}if(event.data==='chat'){window.dispatchEvent(new Event('agent-chat'));return;}if(event.data==='ready'){delete state.revision;}void refresh();};events.onerror=()=>{connectionLost=true;updatePreviewStatus()};refresh();
|
|
557
1786
|
<\/script><script type="module" src="/terminal.js"><\/script></html>`;
|
|
558
1787
|
//#endregion
|
|
1788
|
+
//#region src/presenter-page.ts
|
|
1789
|
+
/** Separate window so speaker notes never appear on the audience surface. */
|
|
1790
|
+
const presenterPage = `<!doctype html>
|
|
1791
|
+
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
1792
|
+
<title>Presenter view — Office Kit</title>
|
|
1793
|
+
<style>
|
|
1794
|
+
*{box-sizing:border-box}body{margin:0;background:#141929;color:#e4e8f0;font:15px system-ui,sans-serif;height:100vh;display:flex;flex-direction:column}header,footer{display:flex;align-items:center;gap:14px;padding:16px;flex-wrap:wrap}h1{font-size:18px;margin:0;flex:1}h2{font-size:13px;color:#b9c2d8;margin:0 0 10px}button{font:inherit;color:inherit;background:#29243e;border:1px solid #675487;border-radius:6px;padding:8px 12px;cursor:pointer}button:disabled{opacity:.4;cursor:default}button:focus-visible{outline:2px solid #bca3ff;outline-offset:3px}main{display:grid;grid-template-columns:minmax(0,3fr) minmax(240px,2fr);gap:20px;padding:0 20px;flex:1;min-height:0;overflow:auto}section{min-width:0}#current{position:relative}#current [hidden]{display:none!important}#current,#next{background:#090c14;aspect-ratio:16/9}#current svg,#next svg{width:100%;height:100%}#notes{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6;max-height:40vh;overflow:auto;padding:14px;background:#1e2538;border-radius:8px}#notes-title{margin-top:20px}#count{flex:1}#message{padding:0 20px;color:#ffc9bd}#elapsed{font-variant-numeric:tabular-nums}footer{justify-content:flex-end}@media(max-width:640px){main{grid-template-columns:1fr}#notes{max-height:none}}
|
|
1795
|
+
</style>
|
|
1796
|
+
<header><h1 id="title">Presenter view</h1><span id="timer-label">Elapsed</span><output id="elapsed">00:00</output><button id="reset">Reset timer</button></header>
|
|
1797
|
+
<p id="message" role="status">Connecting to presentation…</p>
|
|
1798
|
+
<main><section><h2 id="current-title">Current slide</h2><div id="current"></div><button id="media-retry" hidden></button><h2 id="notes-title">Speaker notes</h2><div id="notes" tabindex="0"></div></section><section><h2 id="next-title">Next slide</h2><div id="next"></div><p id="end" hidden></p></section></main>
|
|
1799
|
+
<footer><span id="count" aria-live="polite"></span><button id="prev" disabled>Previous</button><button id="next-button" disabled>Next</button><button id="exit">Exit presentation</button></footer>
|
|
1800
|
+
<script>
|
|
1801
|
+
const byId=id=>document.getElementById(id);
|
|
1802
|
+
const canvases={current:byId('current').attachShadow({mode:'open'}),next:byId('next').attachShadow({mode:'open'})};
|
|
1803
|
+
const displayed={};
|
|
1804
|
+
let locale='en',started=performance.now(),connected=false;
|
|
1805
|
+
const text=(en,ja)=>locale==='ja'?ja:en;
|
|
1806
|
+
// The presenter shows the slide the audience is looking at, so it runs the very
|
|
1807
|
+
// same player over its own copy, resumed at the same point in the same stop,
|
|
1808
|
+
// rather than being told after the fact what is on screen.
|
|
1809
|
+
let makePlayer,animationPlayer,animationKey,animationGeneration,lastState;
|
|
1810
|
+
const presenterReducedMotion=matchMedia('(prefers-reduced-motion: reduce)');
|
|
1811
|
+
import('/animation-player.js').then(module=>{makePlayer=module.createAnimationPlayer;if(lastState)syncAnimation(lastState);},()=>{});
|
|
1812
|
+
function syncAnimation(data){
|
|
1813
|
+
const steps=data.presenting?data.animationSteps:null;
|
|
1814
|
+
if(!steps||!steps.length||!makePlayer){
|
|
1815
|
+
if(animationPlayer){animationPlayer.dispose();animationPlayer=undefined;animationKey=undefined;animationGeneration=undefined;}
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const key=data.index+'\\u0000'+(data.current??'')+'\\u0000'+JSON.stringify(steps);
|
|
1819
|
+
if(key!==animationKey){
|
|
1820
|
+
animationPlayer?.dispose();
|
|
1821
|
+
animationPlayer=makePlayer({root:canvases.current,steps,reducedMotion:()=>presenterReducedMotion.matches});
|
|
1822
|
+
animationKey=key;animationGeneration=undefined;
|
|
1823
|
+
}
|
|
1824
|
+
const progress=data.animation;
|
|
1825
|
+
if(!progress)return;
|
|
1826
|
+
// Follow the audience only when it actually moved. The effects inside a stop
|
|
1827
|
+
// run on the same clock here, so re-running them on every passing report would
|
|
1828
|
+
// restart a fade; a seek that lands on the cursor it is already on — jumping
|
|
1829
|
+
// to the end of the stop in hand, say — still has to be followed, which is
|
|
1830
|
+
// what the generation says and the cursor alone does not.
|
|
1831
|
+
if(progress.generation===animationGeneration)return;
|
|
1832
|
+
animationGeneration=progress.generation;
|
|
1833
|
+
animationPlayer.resume(progress.cursor,progress.elapsed);
|
|
1834
|
+
}
|
|
1835
|
+
let makeMediaPlayer,mediaPlayer,mediaOverlay,mediaKey,mediaLoading=false;
|
|
1836
|
+
function disposeMedia(){mediaPlayer?.dispose();mediaOverlay?.remove();mediaPlayer=undefined;mediaOverlay=undefined;mediaKey=undefined;}
|
|
1837
|
+
function syncMedia(data){
|
|
1838
|
+
const clips=(data.media??[]).filter(clip=>clip.kind!=='online');
|
|
1839
|
+
if(!data.presenting||!data.mediaKey||!clips.length){disposeMedia();byId('media-retry').hidden=true;return;}
|
|
1840
|
+
if(!makeMediaPlayer){
|
|
1841
|
+
if(mediaLoading)return;
|
|
1842
|
+
mediaLoading=true;byId('media-retry').hidden=true;
|
|
1843
|
+
import('/media-player.js?attempt='+Date.now()).then(module=>{
|
|
1844
|
+
makeMediaPlayer=module.createMediaPlayer;mediaLoading=false;if(lastState)syncMedia(lastState);
|
|
1845
|
+
},error=>{
|
|
1846
|
+
mediaLoading=false;console.warn('Could not load presenter media',error);
|
|
1847
|
+
byId('media-retry').textContent=text('Media could not load · Retry','メディアを読み込めませんでした · 再試行');
|
|
1848
|
+
byId('media-retry').hidden=!lastState?.presenting;
|
|
1849
|
+
});
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
const key=data.mediaKey+'\\u0000'+data.current;
|
|
1853
|
+
if(key!==mediaKey){
|
|
1854
|
+
disposeMedia();
|
|
1855
|
+
// Overlays must live inside the canvas shadow tree; the host has no slot
|
|
1856
|
+
// and would not render children appended to its light DOM.
|
|
1857
|
+
mediaOverlay=document.createElement('div');canvases.current.append(mediaOverlay);
|
|
1858
|
+
mediaPlayer=makeMediaPlayer({root:canvases.current,overlayRoot:mediaOverlay,clips,locale,
|
|
1859
|
+
onCommand:command=>{
|
|
1860
|
+
if(window.opener&&!window.opener.closed)window.opener.postMessage({type:'presenter-command',action:'media',index:command,mediaKey:data.mediaKey},location.origin);
|
|
1861
|
+
}});
|
|
1862
|
+
mediaKey=key;
|
|
1863
|
+
}
|
|
1864
|
+
mediaPlayer.sync(data.mediaProgress??[]);
|
|
1865
|
+
}
|
|
1866
|
+
byId('media-retry').onclick=()=>{if(lastState)syncMedia(lastState);};
|
|
1867
|
+
function send(action,index){if(window.opener&&!window.opener.closed)window.opener.postMessage({type:'presenter-command',action,index},location.origin);}
|
|
1868
|
+
for(const canvas of Object.values(canvases))canvas.addEventListener('click',event=>{
|
|
1869
|
+
const link=event.composedPath().find(node=>node instanceof Element&&node.localName==='a');
|
|
1870
|
+
const href=link?.getAttribute('href')??link?.getAttributeNS('http://www.w3.org/1999/xlink','href')??'';
|
|
1871
|
+
if(['#pptx-next-slide','#pptx-prev-slide','#pptx-first-slide','#pptx-last-slide'].includes(href)){
|
|
1872
|
+
event.preventDefault();send('navigation',href);
|
|
1873
|
+
}else if(href.startsWith('#pptx-custom-show?')){
|
|
1874
|
+
event.preventDefault();send('customShow',href);
|
|
1875
|
+
}else if(href==='#pptx-end-show'||href==='#pptx-last-slide-viewed'){
|
|
1876
|
+
event.preventDefault();send(href==='#pptx-end-show'?'exit':'lastViewed');
|
|
1877
|
+
}else if(href.startsWith('#slide-')){
|
|
1878
|
+
event.preventDefault();
|
|
1879
|
+
const number=Number(href.slice(7));
|
|
1880
|
+
if(Number.isInteger(number)&&number>=1)send('jump',number-1);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
function update(data){
|
|
1884
|
+
connected=true;locale=data.locale;document.documentElement.lang=locale;
|
|
1885
|
+
for(const [id,en,ja] of [['title','Presenter view','発表者ビュー'],['timer-label','Elapsed','経過時間'],['reset','Reset timer','タイマーをリセット'],['current-title','Current slide','現在のスライド'],['notes-title','Speaker notes','発表者ノート'],['next-title','Next slide','次のスライド'],['prev','Previous','前へ'],['next-button','Next','次へ'],['exit','Exit presentation','プレゼンテーションを終了']])byId(id).textContent=text(en,ja);
|
|
1886
|
+
document.title=text('Presenter view — Office Kit','発表者ビュー — Office Kit');
|
|
1887
|
+
byId('message').textContent=data.presenting?'':text('Presentation is stopped.','プレゼンテーションは停止中です。');
|
|
1888
|
+
const animation=data.animation&&data.animation.stops>0
|
|
1889
|
+
? ' · '+text('Click '+data.animation.cursor+' of '+data.animation.stops,'クリック '+data.animation.cursor+' / '+data.animation.stops)
|
|
1890
|
+
: '';
|
|
1891
|
+
byId('count').textContent=data.count?text('Slide '+(data.index+1)+' of '+data.count,'スライド '+(data.index+1)+' / '+data.count)+animation:text('No slides','スライドがありません');
|
|
1892
|
+
for(const name of ['current','next']){
|
|
1893
|
+
const svg=data[name];
|
|
1894
|
+
if(displayed[name]!==svg){if(name==='current')disposeMedia();displayed[name]=svg;canvases[name].innerHTML=svg?'<style>svg{display:block;width:100%;height:100%}</style>'+svg:'';}
|
|
1895
|
+
byId(name).style.aspectRatio=String(data.aspectRatio);byId(name).hidden=!svg;
|
|
1896
|
+
}
|
|
1897
|
+
byId('notes').textContent=data.notes||text('No speaker notes.','発表者ノートはありません。');
|
|
1898
|
+
byId('prev').disabled=!data.hasPrevious;
|
|
1899
|
+
// The next click may be an effect on this slide rather than the next slide.
|
|
1900
|
+
byId('next-button').disabled=!(data.hasNext??data.next);
|
|
1901
|
+
byId('exit').disabled=!data.presenting;
|
|
1902
|
+
byId('end').hidden=!!(data.hasNext??data.next);byId('end').textContent=text('End of presentation','プレゼンテーションの最後です');
|
|
1903
|
+
lastState=data;syncAnimation(data);syncMedia(data);
|
|
1904
|
+
}
|
|
1905
|
+
window.addEventListener('message',event=>{
|
|
1906
|
+
if(event.origin!==location.origin||event.source!==window.opener)return;
|
|
1907
|
+
if(event.data?.type==='presenter-state')update(event.data);
|
|
1908
|
+
else if(event.data?.type==='presenter-media-state'&&lastState&&event.data.index===lastState.index&&event.data.mediaKey===lastState.mediaKey){
|
|
1909
|
+
lastState.mediaProgress=event.data.mediaProgress;mediaPlayer?.sync(lastState.mediaProgress);
|
|
1910
|
+
}
|
|
1911
|
+
});
|
|
1912
|
+
byId('prev').onclick=()=>send('previous');byId('next-button').onclick=()=>send('next');byId('exit').onclick=()=>send('exit');
|
|
1913
|
+
byId('reset').onclick=()=>{started=performance.now();byId('elapsed').textContent='00:00';};
|
|
1914
|
+
document.addEventListener('keydown',event=>{
|
|
1915
|
+
if(event.altKey||event.ctrlKey||event.metaKey||(event.key!=='Escape'&&event.target.closest('button')))return;
|
|
1916
|
+
const action=['ArrowRight','ArrowDown','PageDown',' '].includes(event.key)?'next':['ArrowLeft','ArrowUp','PageUp'].includes(event.key)?'previous':event.key==='Escape'?'exit':null;
|
|
1917
|
+
if(action){event.preventDefault();send(action);}
|
|
1918
|
+
});
|
|
1919
|
+
setInterval(()=>{
|
|
1920
|
+
const seconds=Math.floor((performance.now()-started)/1000);byId('elapsed').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0');
|
|
1921
|
+
if(!window.opener||window.opener.closed){disposeMedia();byId('message').textContent=text('The presentation window is closed.','プレゼンテーションのウィンドウは閉じられています。');for(const id of ['prev','next-button','exit'])byId(id).disabled=true;}
|
|
1922
|
+
else if(!connected)send('ready');
|
|
1923
|
+
},250);
|
|
1924
|
+
send('ready');
|
|
1925
|
+
<\/script></html>`;
|
|
1926
|
+
//#endregion
|
|
559
1927
|
//#region src/agent-page.ts
|
|
560
|
-
const agentPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="/terminal.css"><style>${
|
|
561
|
-
body{display:flex;flex-direction:column;background:#191e30;height:100dvh;min-width:0;overflow:auto;overflow-x:hidden} .chat-heading{flex-wrap:wrap} .chat-heading select{min-width:100px}.chat-heading,#chat-context{flex-shrink:0}#claude-panel{min-height:100px}#codex-panel{min-height:240px}
|
|
1928
|
+
const agentPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="/terminal.css"><style>${agentStyles}
|
|
562
1929
|
</style><div class="chat-heading"><select id="chat-provider" aria-label="AI provider"><option value="claude">Claude Code</option><option value="codex">Codex</option></select><button id="chat-reset" hidden>New chat</button></div>
|
|
563
1930
|
<div id="chat-context">No slide selected</div>
|
|
564
|
-
<section id="claude-panel" aria-label="Claude Code terminal"><div class="terminal-toolbar"><span id="terminal-status" role="status">Start Claude Code to edit your slides.</span><button id="terminal-start">Start</button><button id="terminal-stop" hidden>End session</button></div><div class="terminal-frame"><div id="terminal"></div></div></section>
|
|
1931
|
+
<section id="claude-panel" aria-label="Claude Code terminal"><div class="terminal-toolbar"><span id="terminal-status" role="status">Start Claude Code to edit your slides.</span><button id="terminal-start" class="primary">Start</button><button id="terminal-stop" hidden>End session</button></div><div class="terminal-frame"><div id="terminal"></div></div></section>
|
|
565
1932
|
<div id="codex-panel" hidden>
|
|
566
1933
|
<div id="messages" role="log" aria-label="Conversation"><p class="chat-intro">Describe what you want to change. The current slide is included automatically. You can also ask about other slides or the whole deck.<br><br>Uses your locally installed Codex. Sign in through its CLI first. Sending a message allows it to edit this project.</p></div>
|
|
567
|
-
<form id="chat-form"><textarea id="chat-input" aria-label="Message" maxlength="16000" placeholder="Ask for a change…" required></textarea><div class="chat-actions"><span class="chat-shortcut">⌘ / Ctrl + Enter</span><button id="chat-stop" type="button" hidden>Stop</button><button id="chat-send" type="submit">Send</button></div></form>
|
|
1934
|
+
<form id="chat-form"><textarea id="chat-input" aria-label="Message" maxlength="16000" placeholder="Ask for a change…" required></textarea><div class="chat-actions"><span class="chat-shortcut">⌘ / Ctrl + Enter</span><button id="chat-stop" type="button" hidden>Stop</button><button id="chat-send" class="primary" type="submit">Send</button></div></form>
|
|
568
1935
|
<div id="chat-status" role="status">Ready</div>
|
|
569
1936
|
</div>
|
|
570
1937
|
<script>
|
|
@@ -1195,8 +2562,88 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
1195
2562
|
}
|
|
1196
2563
|
//#endregion
|
|
1197
2564
|
//#region src/server.ts
|
|
2565
|
+
const embeddedMediaUrl = /^data:([^;,]+);base64,(.*)$/s;
|
|
2566
|
+
function cacheEmbeddedMedia(media) {
|
|
2567
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2568
|
+
return {
|
|
2569
|
+
cache,
|
|
2570
|
+
published: media.map((item) => {
|
|
2571
|
+
if (item.kind === "online") return item;
|
|
2572
|
+
const match = embeddedMediaUrl.exec(item.src);
|
|
2573
|
+
if (!match) return item;
|
|
2574
|
+
const bytes = Buffer.from(match[2], "base64");
|
|
2575
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
2576
|
+
if (!cache.has(hash)) cache.set(hash, {
|
|
2577
|
+
bytes,
|
|
2578
|
+
contentType: item.contentType ?? match[1]
|
|
2579
|
+
});
|
|
2580
|
+
return {
|
|
2581
|
+
...item,
|
|
2582
|
+
src: `/media/${hash}`
|
|
2583
|
+
};
|
|
2584
|
+
})
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
function mediaRange(value, length) {
|
|
2588
|
+
if (!value) return {
|
|
2589
|
+
start: 0,
|
|
2590
|
+
end: length - 1,
|
|
2591
|
+
partial: false
|
|
2592
|
+
};
|
|
2593
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
2594
|
+
if (!match || !match[1] && !match[2] || length === 0) return null;
|
|
2595
|
+
let start;
|
|
2596
|
+
let end;
|
|
2597
|
+
if (!match[1]) {
|
|
2598
|
+
const suffix = Number(match[2]);
|
|
2599
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0) return null;
|
|
2600
|
+
start = Math.max(0, length - suffix);
|
|
2601
|
+
end = length - 1;
|
|
2602
|
+
} else {
|
|
2603
|
+
start = Number(match[1]);
|
|
2604
|
+
end = match[2] ? Number(match[2]) : length - 1;
|
|
2605
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= length) return null;
|
|
2606
|
+
end = Math.min(end, length - 1);
|
|
2607
|
+
}
|
|
2608
|
+
return {
|
|
2609
|
+
start,
|
|
2610
|
+
end,
|
|
2611
|
+
partial: true
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
const REMEMBERED_DOCUMENTS = 32;
|
|
2615
|
+
const BUNDLED_ASSETS = {
|
|
2616
|
+
"/terminal.js": "terminal-client.js",
|
|
2617
|
+
"/terminal.css": "terminal-client.css",
|
|
2618
|
+
"/editor.js": "editor.js",
|
|
2619
|
+
"/editor.css": "editor.css",
|
|
2620
|
+
"/animation-player.js": "animation-player.js",
|
|
2621
|
+
"/media-player.js": "media-player.js"
|
|
2622
|
+
};
|
|
1198
2623
|
async function serveDeck(entry, port = 4173) {
|
|
1199
2624
|
let latest;
|
|
2625
|
+
let source;
|
|
2626
|
+
let sourceHash;
|
|
2627
|
+
let documentHash;
|
|
2628
|
+
let mediaCache = /* @__PURE__ */ new Map();
|
|
2629
|
+
let publishedMedia = [];
|
|
2630
|
+
const store = editorStore(entry);
|
|
2631
|
+
let saved = await store.read();
|
|
2632
|
+
let conflicts = [];
|
|
2633
|
+
const documents = /* @__PURE__ */ new Map();
|
|
2634
|
+
let uploads = 0;
|
|
2635
|
+
function remember(id, bytes) {
|
|
2636
|
+
documents.set(id, bytes);
|
|
2637
|
+
if (documents.size > REMEMBERED_DOCUMENTS) documents.delete(documents.keys().next().value);
|
|
2638
|
+
}
|
|
2639
|
+
const serverId = randomUUID();
|
|
2640
|
+
const projectId = createHash("sha256").update(resolve(entry)).digest("hex");
|
|
2641
|
+
let publishing = Promise.resolve();
|
|
2642
|
+
function publish(fn) {
|
|
2643
|
+
const result = publishing.then(fn);
|
|
2644
|
+
publishing = result.then(() => {}, () => {});
|
|
2645
|
+
return result;
|
|
2646
|
+
}
|
|
1200
2647
|
let error = null;
|
|
1201
2648
|
let building = false;
|
|
1202
2649
|
let revision = 0;
|
|
@@ -1232,6 +2679,240 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1232
2679
|
}
|
|
1233
2680
|
return result;
|
|
1234
2681
|
}
|
|
2682
|
+
function editorState() {
|
|
2683
|
+
const conflict = !!saved && !!sourceHash && saved.sourceHash !== sourceHash;
|
|
2684
|
+
return {
|
|
2685
|
+
projectId,
|
|
2686
|
+
revision: `${serverId}:${revision}`,
|
|
2687
|
+
previewRevision: revision,
|
|
2688
|
+
documentHash,
|
|
2689
|
+
sourceHash,
|
|
2690
|
+
fileName: basename(entry).replace(/\.[^.]+$/, "") + ".pptx",
|
|
2691
|
+
hasEdits: !!saved,
|
|
2692
|
+
conflict,
|
|
2693
|
+
conflicts: conflict ? conflicts : [],
|
|
2694
|
+
building,
|
|
2695
|
+
error,
|
|
2696
|
+
available: !!latest
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
function update(result) {
|
|
2700
|
+
patch = {};
|
|
2701
|
+
result.slides.forEach((svg, index) => {
|
|
2702
|
+
if (svg !== latest?.slides[index]) patch[index] = svg;
|
|
2703
|
+
});
|
|
2704
|
+
latest = result;
|
|
2705
|
+
({cache: mediaCache, published: publishedMedia} = cacheEmbeddedMedia(result.media));
|
|
2706
|
+
documentHash = sourceFingerprint(result.bytes);
|
|
2707
|
+
revision++;
|
|
2708
|
+
remember(`${serverId}:${revision}`, result.bytes);
|
|
2709
|
+
}
|
|
2710
|
+
function notify() {
|
|
2711
|
+
for (const client of clients) client.write("data: updated\n\n");
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* Accepts an editor upload. An upload based on an older document the editor
|
|
2715
|
+
* loaded (the source rebuilt meanwhile) is merged with the current one.
|
|
2716
|
+
*/
|
|
2717
|
+
function acceptUpload(bytes, basis, keepEdits) {
|
|
2718
|
+
if (!source || !sourceHash || !latest || building || error || closed) return {
|
|
2719
|
+
ok: false,
|
|
2720
|
+
refused: "busy",
|
|
2721
|
+
conflicts: []
|
|
2722
|
+
};
|
|
2723
|
+
const state = editorState();
|
|
2724
|
+
if (state.conflict && !keepEdits) return {
|
|
2725
|
+
ok: false,
|
|
2726
|
+
refused: "conflict",
|
|
2727
|
+
conflicts: state.conflicts
|
|
2728
|
+
};
|
|
2729
|
+
const accepted = {
|
|
2730
|
+
ok: true,
|
|
2731
|
+
source,
|
|
2732
|
+
sourceHash
|
|
2733
|
+
};
|
|
2734
|
+
if (basis === state.revision) return {
|
|
2735
|
+
...accepted,
|
|
2736
|
+
bytes,
|
|
2737
|
+
merged: false
|
|
2738
|
+
};
|
|
2739
|
+
const based = basis !== void 0 && !keepEdits ? documents.get(basis) : void 0;
|
|
2740
|
+
if (!based) return {
|
|
2741
|
+
ok: false,
|
|
2742
|
+
refused: "stale",
|
|
2743
|
+
conflicts: []
|
|
2744
|
+
};
|
|
2745
|
+
const merged = mergeDecks(based, bytes, latest.bytes);
|
|
2746
|
+
return merged.ok ? {
|
|
2747
|
+
...accepted,
|
|
2748
|
+
bytes: merged.bytes,
|
|
2749
|
+
merged: true
|
|
2750
|
+
} : {
|
|
2751
|
+
ok: false,
|
|
2752
|
+
refused: "conflict",
|
|
2753
|
+
conflicts: merged.conflicts
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
async function edit(request, response) {
|
|
2757
|
+
const json = (status, message, extra = {}) => {
|
|
2758
|
+
response.writeHead(status, { "Content-Type": "application/json" });
|
|
2759
|
+
response.end(JSON.stringify({
|
|
2760
|
+
...editorState(),
|
|
2761
|
+
...message ? { message } : {},
|
|
2762
|
+
...extra
|
|
2763
|
+
}));
|
|
2764
|
+
};
|
|
2765
|
+
const refuse = (upload) => json(409, upload.refused === "conflict" ? "These edits conflict with changes in the source." : "The preview changed. Review the current source before saving.", {
|
|
2766
|
+
refused: upload.refused,
|
|
2767
|
+
mergeConflicts: upload.conflicts
|
|
2768
|
+
});
|
|
2769
|
+
if (request.headers.origin !== `http://${request.headers.host}` || request.headers["sec-fetch-site"] === "cross-site") {
|
|
2770
|
+
request.resume();
|
|
2771
|
+
json(403, "Editor requests must come from this preview.");
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
const useSource = request.url === "/editor/resolve" && request.method === "POST";
|
|
2775
|
+
const save = request.url === "/editor/document" && request.method === "PUT";
|
|
2776
|
+
if (!useSource && !save) {
|
|
2777
|
+
request.resume();
|
|
2778
|
+
json(405);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
if (save && request.headers["content-type"] !== "application/octet-stream") {
|
|
2782
|
+
request.resume();
|
|
2783
|
+
json(415);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const chunks = [];
|
|
2787
|
+
let length = 0;
|
|
2788
|
+
const MAX_EDITOR_BYTES = 64 * 1024 * 1024;
|
|
2789
|
+
const basis = request.headers["if-match"];
|
|
2790
|
+
const keepEdits = request.headers["x-editor-resolve"] === "edits";
|
|
2791
|
+
const uploaded = (bytes, upload) => {
|
|
2792
|
+
if (!upload.merged) return {};
|
|
2793
|
+
const id = `${serverId}:upload:${++uploads}`;
|
|
2794
|
+
remember(id, bytes);
|
|
2795
|
+
return {
|
|
2796
|
+
merged: true,
|
|
2797
|
+
uploadRevision: id
|
|
2798
|
+
};
|
|
2799
|
+
};
|
|
2800
|
+
try {
|
|
2801
|
+
for await (const chunk of request) {
|
|
2802
|
+
length += chunk.length;
|
|
2803
|
+
if (length > MAX_EDITOR_BYTES) {
|
|
2804
|
+
json(413, "The editor upload exceeds 64 MiB.");
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
chunks.push(chunk);
|
|
2808
|
+
}
|
|
2809
|
+
if (save && sourceSync) {
|
|
2810
|
+
const received = new Uint8Array(Buffer.concat(chunks));
|
|
2811
|
+
const upload = acceptUpload(received, basis, keepEdits);
|
|
2812
|
+
if (!upload.ok) {
|
|
2813
|
+
refuse(upload);
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
const bytes = upload.bytes;
|
|
2817
|
+
let synced;
|
|
2818
|
+
try {
|
|
2819
|
+
synced = await sourceSync.sync(bytes);
|
|
2820
|
+
} catch (cause) {
|
|
2821
|
+
synced = {
|
|
2822
|
+
written: [],
|
|
2823
|
+
pending: [{
|
|
2824
|
+
kind: "unsupported",
|
|
2825
|
+
slide: null,
|
|
2826
|
+
shapeId: null,
|
|
2827
|
+
description: cause instanceof Error ? cause.message : String(cause)
|
|
2828
|
+
}],
|
|
2829
|
+
complete: false
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
await publish(async () => {
|
|
2833
|
+
if (!source || !sourceHash || closed) {
|
|
2834
|
+
json(409, "The preview changed. Review the current source before saving.", { refused: "busy" });
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (synced.complete) {
|
|
2838
|
+
await store.clear();
|
|
2839
|
+
saved = void 0;
|
|
2840
|
+
update(source);
|
|
2841
|
+
} else {
|
|
2842
|
+
let result;
|
|
2843
|
+
try {
|
|
2844
|
+
result = (await renderDeck(bytes, source.dependencies)).result;
|
|
2845
|
+
} catch (cause) {
|
|
2846
|
+
json(400, cause instanceof Error ? cause.message : String(cause));
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const edits = {
|
|
2850
|
+
sourceHash,
|
|
2851
|
+
base: source.bytes,
|
|
2852
|
+
bytes
|
|
2853
|
+
};
|
|
2854
|
+
await store.write(edits);
|
|
2855
|
+
saved = edits;
|
|
2856
|
+
update(result);
|
|
2857
|
+
}
|
|
2858
|
+
json(200, void 0, {
|
|
2859
|
+
...uploaded(received, upload),
|
|
2860
|
+
writeBack: {
|
|
2861
|
+
written: synced.written.length,
|
|
2862
|
+
pending: synced.pending
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
notify();
|
|
2866
|
+
});
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
await publish(async () => {
|
|
2870
|
+
if (useSource) {
|
|
2871
|
+
if (!source || !sourceHash || building || error || closed || basis !== editorState().revision) {
|
|
2872
|
+
json(409, "The preview changed. Review the current source before saving.", { refused: "busy" });
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
await store.clear();
|
|
2876
|
+
saved = void 0;
|
|
2877
|
+
update(source);
|
|
2878
|
+
json(200);
|
|
2879
|
+
notify();
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
const received = new Uint8Array(Buffer.concat(chunks));
|
|
2883
|
+
const upload = acceptUpload(received, basis, keepEdits);
|
|
2884
|
+
if (!upload.ok) {
|
|
2885
|
+
refuse(upload);
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
const started = generation;
|
|
2889
|
+
let result;
|
|
2890
|
+
try {
|
|
2891
|
+
result = (await renderDeck(upload.bytes, upload.source.dependencies)).result;
|
|
2892
|
+
} catch (cause) {
|
|
2893
|
+
json(400, cause instanceof Error ? cause.message : String(cause));
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
if (started !== generation) {
|
|
2897
|
+
json(409, "Source changed during save.", { refused: "busy" });
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const edits = {
|
|
2901
|
+
sourceHash: upload.sourceHash,
|
|
2902
|
+
base: upload.source.bytes,
|
|
2903
|
+
bytes: upload.bytes
|
|
2904
|
+
};
|
|
2905
|
+
await store.write(edits);
|
|
2906
|
+
saved = edits;
|
|
2907
|
+
update(result);
|
|
2908
|
+
json(200, void 0, uploaded(received, upload));
|
|
2909
|
+
notify();
|
|
2910
|
+
});
|
|
2911
|
+
} catch (cause) {
|
|
2912
|
+
if (!response.headersSent) json(500, cause instanceof Error ? cause.message : String(cause));
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
const sourceSync = process.env.OFFICE_KIT_TSX_WRITE_BACK === "1" ? createSourceSync(resolve(entry), () => source, verify) : void 0;
|
|
1235
2916
|
const textEditor = createTextEditor(resolve(entry), () => {
|
|
1236
2917
|
if (!latest || building || pending || timer || error) throw new Error("Wait for a successful preview build.");
|
|
1237
2918
|
return {
|
|
@@ -1251,6 +2932,39 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1251
2932
|
response.writeHead(404).end();
|
|
1252
2933
|
return;
|
|
1253
2934
|
}
|
|
2935
|
+
const mediaMatch = request.url?.match(/^\/media\/([a-f0-9]{64})(?:\?.*)?$/);
|
|
2936
|
+
if (mediaMatch) {
|
|
2937
|
+
if (request.method !== "GET") {
|
|
2938
|
+
response.writeHead(405).end();
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
const cached = mediaCache.get(mediaMatch[1]);
|
|
2942
|
+
if (!cached) {
|
|
2943
|
+
response.writeHead(404).end();
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
const range = mediaRange(request.headers.range, cached.bytes.length);
|
|
2947
|
+
const immutableHeaders = {
|
|
2948
|
+
"Accept-Ranges": "bytes",
|
|
2949
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
2950
|
+
"Content-Type": cached.contentType
|
|
2951
|
+
};
|
|
2952
|
+
if (!range) {
|
|
2953
|
+
response.writeHead(416, {
|
|
2954
|
+
...immutableHeaders,
|
|
2955
|
+
"Content-Range": `bytes */${cached.bytes.length}`
|
|
2956
|
+
}).end();
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const bytes = cached.bytes.subarray(range.start, range.end + 1);
|
|
2960
|
+
response.writeHead(range.partial ? 206 : 200, {
|
|
2961
|
+
...immutableHeaders,
|
|
2962
|
+
"Content-Length": bytes.length,
|
|
2963
|
+
...range.partial ? { "Content-Range": `bytes ${range.start}-${range.end}/${cached.bytes.length}` } : {}
|
|
2964
|
+
});
|
|
2965
|
+
response.end(bytes);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
1254
2968
|
if (request.url === "/history" && request.method === "GET") {
|
|
1255
2969
|
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(history.state()));
|
|
1256
2970
|
return;
|
|
@@ -1364,12 +3078,25 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1364
3078
|
buildError: error
|
|
1365
3079
|
};
|
|
1366
3080
|
});
|
|
1367
|
-
} else if (request.method === "GET" && [
|
|
1368
|
-
const asset = request.url
|
|
3081
|
+
} else if (request.method === "GET" && BUNDLED_ASSETS[(request.url ?? "").split("?")[0]] !== void 0) {
|
|
3082
|
+
const asset = BUNDLED_ASSETS[(request.url ?? "").split("?")[0]];
|
|
1369
3083
|
readFile(new URL(asset, import.meta.url)).then((bytes) => {
|
|
1370
3084
|
response.writeHead(200, { "Content-Type": asset.endsWith(".css") ? "text/css" : "text/javascript" });
|
|
1371
3085
|
response.end(bytes);
|
|
1372
|
-
}, () => response.writeHead(500).end("
|
|
3086
|
+
}, () => response.writeHead(500).end("Preview assets unavailable. Rebuild pptx-dev."));
|
|
3087
|
+
} else if (request.url?.startsWith("/editor/") && request.method !== "GET") edit(request, response);
|
|
3088
|
+
else if (request.method === "GET" && request.url === "/editor/state") response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(editorState()));
|
|
3089
|
+
else if (request.method === "GET" && ["/editor/document", "/editor/source"].includes(request.url ?? "")) {
|
|
3090
|
+
const result = request.url === "/editor/source" ? source : latest;
|
|
3091
|
+
if (!result) {
|
|
3092
|
+
response.writeHead(503).end();
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
response.writeHead(200, {
|
|
3096
|
+
"Content-Type": "application/octet-stream",
|
|
3097
|
+
ETag: editorState().revision
|
|
3098
|
+
});
|
|
3099
|
+
response.end(result.bytes);
|
|
1373
3100
|
} else if (request.method !== "GET") response.writeHead(405).end();
|
|
1374
3101
|
else if (request.url === "/events") {
|
|
1375
3102
|
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
@@ -1388,6 +3115,13 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1388
3115
|
count: latest?.slides.length ?? 0
|
|
1389
3116
|
} : { slides: latest?.slides ?? [] },
|
|
1390
3117
|
aspectRatio: latest?.aspectRatio ?? 16 / 9,
|
|
3118
|
+
transitions: latest?.transitions ?? [],
|
|
3119
|
+
animations: latest?.animations ?? [],
|
|
3120
|
+
media: publishedMedia,
|
|
3121
|
+
showProperties: latest?.showProperties ?? null,
|
|
3122
|
+
customShows: latest?.customShows ?? [],
|
|
3123
|
+
notes: latest?.notes ?? [],
|
|
3124
|
+
hiddenSlides: latest?.hiddenSlides ?? [],
|
|
1391
3125
|
error,
|
|
1392
3126
|
diagnostics: latest?.diagnostics ?? []
|
|
1393
3127
|
}));
|
|
@@ -1397,6 +3131,12 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1397
3131
|
"Content-Disposition": "attachment; filename=\"deck.pptx\""
|
|
1398
3132
|
});
|
|
1399
3133
|
response.end(latest.bytes);
|
|
3134
|
+
} else if (request.url === "/editor") {
|
|
3135
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3136
|
+
response.end("<!doctype html><html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>Office Kit Editor</title><link rel=\"stylesheet\" href=\"/editor.css\"><body><script type=\"module\" src=\"/editor.js\"><\/script></body></html>");
|
|
3137
|
+
} else if (request.url === "/presenter") {
|
|
3138
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3139
|
+
response.end(presenterPage);
|
|
1400
3140
|
} else if (request.url === "/") {
|
|
1401
3141
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1402
3142
|
response.end(page);
|
|
@@ -1422,15 +3162,33 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1422
3162
|
for (const client of clients) client.write("data: building\n\n");
|
|
1423
3163
|
try {
|
|
1424
3164
|
const result = await builder.build();
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
result.
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
3165
|
+
await publish(async () => {
|
|
3166
|
+
if (started !== generation || closed) return;
|
|
3167
|
+
const nextHash = sourceFingerprint(result.bytes);
|
|
3168
|
+
let unmerged = [];
|
|
3169
|
+
let merged;
|
|
3170
|
+
if (saved?.base && saved.sourceHash !== nextHash) {
|
|
3171
|
+
const merge = mergeDecks(saved.base, saved.bytes, result.bytes);
|
|
3172
|
+
if (!merge.ok) unmerged = merge.conflicts;
|
|
3173
|
+
else if (sourceFingerprint(merge.bytes) === nextHash) merged = null;
|
|
3174
|
+
else merged = {
|
|
3175
|
+
sourceHash: nextHash,
|
|
3176
|
+
base: result.bytes,
|
|
3177
|
+
bytes: merge.bytes
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
const edits = merged === void 0 ? saved : merged ?? void 0;
|
|
3181
|
+
const edited = edits ? (await renderDeck(edits.bytes, result.dependencies)).result : result;
|
|
3182
|
+
if (started !== generation || closed) return;
|
|
3183
|
+
if (merged) await store.write(merged);
|
|
3184
|
+
else if (merged === null) await store.clear();
|
|
3185
|
+
saved = edits;
|
|
3186
|
+
source = result;
|
|
3187
|
+
sourceHash = nextHash;
|
|
3188
|
+
conflicts = unmerged;
|
|
3189
|
+
update(edited);
|
|
1432
3190
|
error = null;
|
|
1433
|
-
}
|
|
3191
|
+
});
|
|
1434
3192
|
} catch (cause) {
|
|
1435
3193
|
if (started === generation) error = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
|
|
1436
3194
|
} finally {
|
|
@@ -1487,6 +3245,7 @@ async function serveDeck(entry, port = 4173) {
|
|
|
1487
3245
|
watcher.close();
|
|
1488
3246
|
await Promise.all([...sessions.values()].flatMap(({ terminal, chat }) => [terminal.close(), chat.close()]));
|
|
1489
3247
|
await builder.close();
|
|
3248
|
+
await publishing;
|
|
1490
3249
|
for (const client of clients) client.end();
|
|
1491
3250
|
await new Promise((done, reject) => server.close((cause) => cause ? reject(cause) : done()));
|
|
1492
3251
|
}
|