pindeck 3.0.0-beta.2 → 3.1.0-beta.1
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 +94 -16
- package/front.mjs +318 -0
- package/lib/deck-list.mjs +49 -0
- package/lib/deck-target.mjs +25 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +26 -8
- package/lib/login.mjs +61 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +24 -3
- package/public/editor.js +78 -21
- package/public/front.css +14 -0
- package/public/index.html +9 -1
- package/server.mjs +116 -101
package/public/editor.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
|
|
2
2
|
|
|
3
3
|
const $ = (id) => document.getElementById(id);
|
|
4
|
+
/* The editor's own folder: / when it runs alone, /d/<deck>/ behind the front (pindeck), which serves several
|
|
5
|
+
decks on one port. Every URL of this deck's server starts here. */
|
|
6
|
+
const BASE = new URL(".", location.href).pathname;
|
|
7
|
+
const FRONT = BASE.startsWith("/d/");
|
|
8
|
+
const SLIDE_KEY = FRONT ? `editor.slide:${BASE}` : "editor.slide"; // decks share the origin, not their slide
|
|
4
9
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
5
10
|
/* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
|
|
6
11
|
(409, code "stale") if the files have changed since. A change that went through gives the new revision. */
|
|
@@ -9,7 +14,7 @@ const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidd
|
|
|
9
14
|
const api = async (p, body) => {
|
|
10
15
|
const change = body !== undefined && CHANGES.has(p.split("?")[0]);
|
|
11
16
|
if (change && state.rev) body = { ...body, rev: state.rev };
|
|
12
|
-
const res = await fetch("
|
|
17
|
+
const res = await fetch(BASE + "api/" + p, {
|
|
13
18
|
method: body === undefined ? "GET" : "POST",
|
|
14
19
|
headers: { "content-type": "application/json" },
|
|
15
20
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -18,7 +23,7 @@ const api = async (p, body) => {
|
|
|
18
23
|
return res;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
|
-
const savedSlide = (() => { try { return +localStorage.getItem(
|
|
26
|
+
const savedSlide = (() => { try { return +localStorage.getItem(SLIDE_KEY) || 0; } catch { return 0; } })();
|
|
22
27
|
const state = {
|
|
23
28
|
status: {}, slides: [], current: savedSlide, pins: [], mode: "select", sel: null, // mode: "select" (move, resize) or "pin"
|
|
24
29
|
busy: false, status: {}, turns: new Map(), // turnId -> { undone }
|
|
@@ -31,8 +36,8 @@ let measure = null; // { iframe, bridge }
|
|
|
31
36
|
let measureReady = null, measureResolve = null;
|
|
32
37
|
|
|
33
38
|
// showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
|
|
34
|
-
//
|
|
35
|
-
function deckSrc(mode, extra = "") { return
|
|
39
|
+
// deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
|
|
40
|
+
function deckSrc(mode, extra = "") { return `${BASE}deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
|
|
36
41
|
|
|
37
42
|
function loadMain() {
|
|
38
43
|
const f = document.createElement("iframe");
|
|
@@ -83,7 +88,7 @@ window.__editorHost = {
|
|
|
83
88
|
},
|
|
84
89
|
slideChanged(index) {
|
|
85
90
|
state.current = index;
|
|
86
|
-
try { localStorage.setItem(
|
|
91
|
+
try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
|
|
87
92
|
renderSlides();
|
|
88
93
|
renderToolbar();
|
|
89
94
|
loadNotes();
|
|
@@ -229,7 +234,8 @@ function renderSlides() {
|
|
|
229
234
|
${s.kind !== "content" ? `<span class="kind">${esc(s.kind.replace(/-slide$/, ""))}</span>` : ""}
|
|
230
235
|
${s.hidden ? '<span class="kind hid">hidden</span>' : ""}</span>
|
|
231
236
|
${counts[i] ? `<span class="badge" title="${counts[i]} pin(s)">${counts[i]}</span>` : "<span></span>"}
|
|
232
|
-
<button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" :
|
|
237
|
+
<button class="eye" data-eye="${i}" title="${s.hidden ? "Show this slide again" : state.status?.slideSelector ?
|
|
238
|
+
"Hide this slide (left out of PDFs)" : "Hide this slide (skipped when presenting)"}"
|
|
233
239
|
aria-label="${s.hidden ? "Show" : "Hide"} slide ${i + 1}">${s.hidden ? EYE_ICON : EYE_OFF_ICON}</button>
|
|
234
240
|
</li>`).join("");
|
|
235
241
|
$("slide-list").querySelector("li.active")?.scrollIntoView({ block: "nearest" });
|
|
@@ -296,7 +302,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
|
|
|
296
302
|
if (!res.ok) return toast(d.error || "Could not move the slide.");
|
|
297
303
|
if (d.unchanged) return;
|
|
298
304
|
state.current = d.index; // the reload opens the moved slide
|
|
299
|
-
try { localStorage.setItem(
|
|
305
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
300
306
|
loadMain();
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -365,7 +371,7 @@ notesBox.addEventListener("keydown", (e) => {
|
|
|
365
371
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
366
372
|
addEventListener("pagehide", () => {
|
|
367
373
|
if (notesDirty() && !state.busy)
|
|
368
|
-
navigator.sendBeacon("
|
|
374
|
+
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
|
|
369
375
|
});
|
|
370
376
|
function setNotesOpen(open) {
|
|
371
377
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -464,6 +470,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
|
|
|
464
470
|
const closeMenus = () => setMenu(null, null, false);
|
|
465
471
|
document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
|
|
466
472
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
|
|
473
|
+
/* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
|
|
474
|
+
down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
|
|
475
|
+
its chat and undo, and the assistant goes on with a turn it has begun. */
|
|
476
|
+
if (FRONT) {
|
|
477
|
+
$("deck-btn").disabled = false;
|
|
478
|
+
$("deck-btn").title = "Open another deck";
|
|
479
|
+
$("deck-btn").querySelector(".caret").hidden = false;
|
|
480
|
+
wireMenu("deck-btn", "deck-menu", async () => {
|
|
481
|
+
const menu = $("deck-menu");
|
|
482
|
+
menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
|
|
483
|
+
let decks = [];
|
|
484
|
+
try { decks = (await (await fetch("/api/decks")).json()).decks; }
|
|
485
|
+
catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
|
|
486
|
+
const roots = [...new Set(decks.map((d) => d.root))];
|
|
487
|
+
menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
|
|
488
|
+
decks.filter((d) => d.root === root).map((d) => {
|
|
489
|
+
const here = BASE === `/d/${d.key}/`;
|
|
490
|
+
return d.problem
|
|
491
|
+
? `<button class="opt" role="menuitem" disabled title="${esc(d.problem)}"><span class="tick"></span><span><b>${esc(d.rel)}</b><span class="why">${esc(d.problem.replace(/^\S+ /, "").split(". ")[0])}.</span></span></button>`
|
|
492
|
+
: `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
|
|
493
|
+
`<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
|
|
494
|
+
}).join("")).join("") +
|
|
495
|
+
'<div class="menu-note">A deck opens here (⌘-click: in a new tab), with its own chat and undo. The assistant finishes its turn in the deck you leave.</div>';
|
|
496
|
+
});
|
|
497
|
+
}
|
|
467
498
|
const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
|
|
468
499
|
wireMenu("export-btn", "export-menu");
|
|
469
500
|
$("export-menu").addEventListener("click", async (e) => {
|
|
@@ -492,8 +523,7 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
492
523
|
});
|
|
493
524
|
|
|
494
525
|
$("present").onclick = () => {
|
|
495
|
-
|
|
496
|
-
window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
|
|
526
|
+
window.open(`${BASE}deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
|
|
497
527
|
};
|
|
498
528
|
|
|
499
529
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -571,17 +601,21 @@ function md(s) {
|
|
|
571
601
|
}).join("");
|
|
572
602
|
return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
|
|
573
603
|
}
|
|
604
|
+
/* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
|
|
605
|
+
an older one says so (see the error listener below). */
|
|
606
|
+
const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
|
|
607
|
+
`<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
|
|
574
608
|
function entryHtml(e) {
|
|
575
609
|
switch (e.kind) {
|
|
576
610
|
case "user":
|
|
577
611
|
return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
|
|
578
612
|
`<span class="chip sel" title="${esc(e.selection.eid)}"><span class="n">▢</span> S${e.selection.slide} · ${esc(e.selection.label)}</span>` : ""}${(e.pins || []).map((p) =>
|
|
579
613
|
`<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
|
|
580
|
-
e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
581
|
-
`<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="
|
|
614
|
+
e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
|
|
615
|
+
`<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="${BASE}deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
|
|
582
616
|
case "assistant": return md(e.text);
|
|
583
617
|
case "tool":
|
|
584
|
-
return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
|
|
618
|
+
return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.shot ? " " + shotChip(e.shot) : ""}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
|
|
585
619
|
case "edit": {
|
|
586
620
|
const t = state.turns.get(e.turnId);
|
|
587
621
|
return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
|
|
@@ -629,7 +663,23 @@ function clearChat() {
|
|
|
629
663
|
$("chat-empty").hidden = false;
|
|
630
664
|
}
|
|
631
665
|
|
|
666
|
+
// a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
|
|
667
|
+
chatEl.addEventListener("error", (e) => {
|
|
668
|
+
const chip = e.target.closest?.("[data-shot]");
|
|
669
|
+
if (!chip || e.target.tagName !== "IMG") return;
|
|
670
|
+
chip.removeAttribute("data-shot");
|
|
671
|
+
chip.classList.add("gone");
|
|
672
|
+
chip.title = "Only the newest screenshots are kept";
|
|
673
|
+
chip.textContent = "📷 no longer kept";
|
|
674
|
+
}, true);
|
|
632
675
|
chatEl.addEventListener("click", async (e) => {
|
|
676
|
+
const s = e.target.closest("[data-shot]");
|
|
677
|
+
if (s) {
|
|
678
|
+
$("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
|
|
679
|
+
$("shot-title").textContent = s.dataset.shot;
|
|
680
|
+
$("shot-dialog").showModal();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
633
683
|
const d = e.target.closest("[data-diff]");
|
|
634
684
|
if (d) {
|
|
635
685
|
const res = await api(`turn/${d.dataset.diff}/diff`);
|
|
@@ -651,6 +701,9 @@ chatEl.addEventListener("click", async (e) => {
|
|
|
651
701
|
}
|
|
652
702
|
});
|
|
653
703
|
$("diff-close").onclick = () => $("diff-dialog").close();
|
|
704
|
+
$("shot-close").onclick = () => $("shot-dialog").close();
|
|
705
|
+
// Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
|
|
706
|
+
$("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
|
|
654
707
|
|
|
655
708
|
function setBusy(busy) {
|
|
656
709
|
state.busy = busy;
|
|
@@ -739,6 +792,7 @@ document.addEventListener("keydown", (e) => {
|
|
|
739
792
|
});
|
|
740
793
|
|
|
741
794
|
/* ---- Server events ---------------------------------------------------------------------------------- */
|
|
795
|
+
let shownProblem = null; // the deck problem last shown, so a status update does not repeat it
|
|
742
796
|
function applyStatus(s) {
|
|
743
797
|
state.status = s;
|
|
744
798
|
// the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
|
|
@@ -746,7 +800,7 @@ function applyStatus(s) {
|
|
|
746
800
|
const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
|
|
747
801
|
const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
|
|
748
802
|
$("deck-name").textContent = name;
|
|
749
|
-
$("deck-
|
|
803
|
+
$("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
|
|
750
804
|
if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
|
|
751
805
|
document.title = `${name} · PinDeck`;
|
|
752
806
|
}
|
|
@@ -785,10 +839,13 @@ function applyStatus(s) {
|
|
|
785
839
|
$("server-note").textContent = caps.label ? `Server: ${caps.label}` : "";
|
|
786
840
|
$("server-note").title = off.length ? `Not available on this server:\n${off.join("\n")}\n\nWhy:\n${reasons.join("\n")}` : "";
|
|
787
841
|
$("add-slide-btn").disabled = !!s.busy;
|
|
842
|
+
// the deck's slide tags stopped working while PinDeck runs (at start, such a deck is refused)
|
|
843
|
+
if (s.deckProblem && s.deckProblem !== shownProblem) toast(s.deckProblem, { ms: 20000 });
|
|
844
|
+
shownProblem = s.deckProblem;
|
|
788
845
|
renderToolbar();
|
|
789
846
|
}
|
|
790
847
|
function connect() {
|
|
791
|
-
const es = new EventSource("
|
|
848
|
+
const es = new EventSource(BASE + "api/events");
|
|
792
849
|
es.addEventListener("hello", (ev) => {
|
|
793
850
|
const d = JSON.parse(ev.data);
|
|
794
851
|
// reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
|
|
@@ -864,7 +921,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
|
|
|
864
921
|
if (!res.ok) return toast(d.error || "Could not add the slide.");
|
|
865
922
|
state.current = d.index; // the reload opens the new slide ...
|
|
866
923
|
state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
|
|
867
|
-
try { localStorage.setItem(
|
|
924
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
868
925
|
loadMain();
|
|
869
926
|
});
|
|
870
927
|
|
|
@@ -877,7 +934,7 @@ $("import-file").addEventListener("change", async () => {
|
|
|
877
934
|
$("import-file").value = "";
|
|
878
935
|
if (!file) return;
|
|
879
936
|
toast(`Reading ${file.name}…`);
|
|
880
|
-
const res = await fetch("
|
|
937
|
+
const res = await fetch(BASE + "api/import", { method: "POST", body: file,
|
|
881
938
|
headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
|
|
882
939
|
const d = await res.json().catch(() => ({}));
|
|
883
940
|
if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
|
|
@@ -921,7 +978,7 @@ function showImportThumbs(ev) {
|
|
|
921
978
|
for (const c of importChecks()) {
|
|
922
979
|
const img = new Image();
|
|
923
980
|
img.alt = "";
|
|
924
|
-
img.src =
|
|
981
|
+
img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
|
|
925
982
|
c.closest(".thumb").append(img);
|
|
926
983
|
}
|
|
927
984
|
}
|
|
@@ -939,7 +996,7 @@ $("import-dialog").addEventListener("close", async () => {
|
|
|
939
996
|
toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
|
|
940
997
|
(d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
|
|
941
998
|
state.current = d.index;
|
|
942
|
-
try { localStorage.setItem(
|
|
999
|
+
try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
|
|
943
1000
|
loadMain();
|
|
944
1001
|
});
|
|
945
1002
|
|
|
@@ -1086,7 +1143,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
|
|
|
1086
1143
|
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
|
|
1087
1144
|
|
|
1088
1145
|
async function uploadBlob(blob, name) {
|
|
1089
|
-
const res = await fetch("
|
|
1146
|
+
const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
|
|
1090
1147
|
headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
|
|
1091
1148
|
const d = await res.json().catch(() => ({}));
|
|
1092
1149
|
if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
|
|
@@ -1174,7 +1231,7 @@ function renderAttachments() {
|
|
|
1174
1231
|
a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
|
|
1175
1232
|
fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
|
|
1176
1233
|
return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
|
|
1177
|
-
${a.kind === "image" ? `<img class="thumb" src="${a.preview || "
|
|
1234
|
+
${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
|
|
1178
1235
|
<span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
|
|
1179
1236
|
${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
|
|
1180
1237
|
<button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
|
package/public/front.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* The front's own pages: the list of decks (a folder without index.html) and why a deck cannot be opened */
|
|
2
|
+
body { margin: 0; background: #f6f6f4; color: #1d1f22; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
3
|
+
.front { max-width: 44em; margin: 3em auto; padding: 0 16px; }
|
|
4
|
+
.front h1 { font-size: 22px; margin: 0 0 .8em; }
|
|
5
|
+
.front h2 { font-size: 12px; font-weight: 600; color: #6b6f76; margin: 1.6em 0 .4em; overflow-wrap: anywhere; }
|
|
6
|
+
.front ul { list-style: none; margin: 0; padding: 0; border: 1px solid #e3e3df; border-radius: 10px; background: #fff; }
|
|
7
|
+
.front li { padding: 9px 14px; border-top: 1px solid #ececea; }
|
|
8
|
+
.front li:first-child { border-top: 0; }
|
|
9
|
+
.front li a { color: #1d1f22; font-weight: 500; text-decoration: none; }
|
|
10
|
+
.front li a:hover { color: #e8710a; }
|
|
11
|
+
.front li small { color: #6b6f76; margin-left: 6px; }
|
|
12
|
+
.front li.off { color: #8a8e94; }
|
|
13
|
+
.front li.off small { display: block; margin: 2px 0 0; font-size: 12px; }
|
|
14
|
+
.front pre { white-space: pre-wrap; background: #fff; border: 1px solid #e3e3df; border-radius: 8px; padding: 10px 12px; font-size: 13px; }
|
package/public/index.html
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
<button id="prev" class="icon" title="Previous slide (←)" aria-label="Previous slide">‹</button>
|
|
36
36
|
<span id="slide-pos" class="pos">– / –</span>
|
|
37
37
|
<button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
|
|
38
|
-
<span
|
|
38
|
+
<span class="menu-wrap deck-wrap">
|
|
39
|
+
<button id="deck-btn" class="deck-btn" aria-haspopup="menu" aria-expanded="false" disabled><span id="deck-name"></span><span class="caret" hidden>▾</span></button>
|
|
40
|
+
<div id="deck-menu" class="menu left deck-menu" role="menu" hidden></div>
|
|
41
|
+
</span>
|
|
39
42
|
<span id="server-note" class="server-note" hidden></span>
|
|
40
43
|
</div>
|
|
41
44
|
<div class="group">
|
|
@@ -172,6 +175,11 @@
|
|
|
172
175
|
</form>
|
|
173
176
|
</dialog>
|
|
174
177
|
|
|
178
|
+
<dialog id="shot-dialog" class="diff-dialog shot-dialog">
|
|
179
|
+
<div class="dialog-head"><span id="shot-title">Screenshot</span><button class="small" id="shot-close">Close</button></div>
|
|
180
|
+
<img id="shot-img" alt="Screenshot">
|
|
181
|
+
</dialog>
|
|
182
|
+
|
|
175
183
|
<dialog id="diff-dialog" class="diff-dialog">
|
|
176
184
|
<div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
|
|
177
185
|
<pre id="diff-body" class="diff"></pre>
|