diff --git a/src/client/app.css b/src/client/app.css index a33153a..7edc66d 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -1760,6 +1760,29 @@ body.selecting #selectBtn { overflow: hidden; border-radius: var(--radius-sm); } +/* A picture previews as itself, filling the same slot the paper scrap uses. */ +.artifactshot { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius-sm); + background: var(--bg-sunk); +} +/* A clip or a PDF has no legible scrap: a plain mark of what it is, centred. */ +.artifactmark { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: var(--radius-sm); + background: var(--bg-sunk); + color: var(--ink-faint); +} +.artifactmark svg { + width: 18px; + height: 18px; +} .artifactpaper { position: absolute; left: 11px; @@ -2052,6 +2075,52 @@ body.resizing { border: 0; background: #fff; } +/* Media in the pane: shown at its own size up to the pane's width, never + cropped, and centred so a tall chart and a wide one both sit right. */ +.panemedia { + display: block; + max-width: 100%; + margin: 0 auto; + border-radius: var(--radius-sm); +} +img.panemedia { + height: auto; + background: var(--bg-sunk); +} +audio.panemedia { + width: 100%; +} +/* A PDF gets the pane's whole height — it brings its own viewer chrome. */ +.panebody:has(> .pdfframe) { + padding: 0; + overflow: hidden; +} +.pdfframe { + width: 100%; + height: 100%; + border: 0; + border-radius: 0; + background: var(--bg-sunk); +} +/* Nothing to render: what it is, and the way to get it. */ +.panefile { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 20px; + text-align: center; +} +.panefilename { + font-size: 15px; + color: var(--ink); + word-break: break-all; +} +.panefilenote { + font-size: 13px; + color: var(--ink-muted); + margin-bottom: 6px; +} /* The document reads at a measure, like the conversation does — but centred in the pane. Left-aligned, every pixel the pane had beyond the measure piled up on the right and read as a huge empty gutter. */ @@ -5134,3 +5203,25 @@ body.lb-open { color: var(--ink-muted); text-align: center; } +/* A published picture or clip is the page, so it gets the room and stays + uncropped; a PDF brings its own viewer and takes the height. */ +.sharemedia { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + border-radius: var(--radius-sm); +} +audio.sharemedia { + width: min(100%, var(--measure)); +} +.sharebody:has(> .sharepdf) { + padding: 0; + overflow: hidden; +} +.sharepdf { + width: 100%; + height: 100%; + border: 0; + border-radius: 0; +} diff --git a/src/client/app.js b/src/client/app.js index 87a0483..56111f6 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -29,6 +29,7 @@ import { CHEV_ICON as CHEV, COPY_ICON, FILE_ICON as FILE_SVG, + FILM_ICON, GRIP_ICON as GRIP, ASK_ICON as ICON_ASK, BLANK_ICON as ICON_BLANK, @@ -41,6 +42,7 @@ import { TERMINAL_ICON as ICON_TERMINAL, TOOL_ICON as ICON_TOOL, IMAGE_ICON, + MUSIC_ICON, SEND_ICON as SEND, X_ICON, } from "./icons.js"; @@ -1620,6 +1622,7 @@ import { mountSidebar } from "./sidebar.js"; // Only a document with a rendering distinct from its source has a source to // switch to; markdown in the pane already IS the rendering. $("paneSourceBtn").hidden = mode !== "doc" || !isHtmlDoc(paneDoc); + $("paneCopy").textContent = isMediaDoc(paneDoc) ? "Download" : "Copy"; $("paneVersions").hidden = mode !== "doc" || paneVersions.length < 2; paintPublicChip(); $("paneSourceBtn").textContent = paneSource ? "Preview" : "Source"; @@ -1732,6 +1735,51 @@ import { mountSidebar } from "./sidebar.js"; function isHtmlDoc(doc) { return !!doc && /^text\/html\b/.test(doc.mime || ""); } + /* + * What the pane can do with a document. The mime is a guess from the filename + * (mimeForName, server side), so text is the fallback and the BYTES get the + * last word: `looksBinaryText` catches a .dat that turned out to be a PNG. + * Media never goes through the markdown renderer — that's what painted raw + * bytes into the pane. + */ + function docKind(doc) { + var m = (doc && doc.mime) || ""; + if (/^text\/html\b/.test(m)) return "html"; + if (/^image\//.test(m)) return "image"; + if (/^video\//.test(m)) return "video"; + if (/^audio\//.test(m)) return "audio"; + if (/^application\/pdf\b/.test(m)) return "pdf"; + return "text"; + } + function isMediaDoc(doc) { + var k = docKind(doc); + return k === "image" || k === "video" || k === "audio" || k === "pdf"; + } + // The twin of looksBinary() in src/http.ts — same rule (NUL, U+FFFD, or too + // many control characters), applied to what a fetch decoded rather than to a + // file being uploaded. + function looksBinaryText(s) { + if (!s) return false; + if (s.indexOf("\u0000") !== -1) return true; + var n = Math.min(s.length, 8192), + bad = 0; + for (var i = 0; i < n; i++) { + var c = s.charCodeAt(i); + if (c === 0xfffd || c < 9 || (c > 13 && c < 32)) bad++; + } + return n > 0 && bad / n > 0.02; + } + // A one-word noun for the card and the pane. Documents are the common case, so + // they keep the plain name. + function docNoun(doc) { + var k = docKind(doc); + if (k === "html") return "Page"; + if (k === "pdf") return "PDF"; + if (k === "image") return "Image"; + if (k === "video") return "Video"; + if (k === "audio") return "Audio"; + return "Document"; + } /** * A page, rendered as a page — inside a frame that cannot reach the app. * @@ -1757,10 +1805,79 @@ import { mountSidebar } from "./sidebar.js"; function renderHtmlSource(body, text) { renderStaticMd(body, "```html\n" + text + "\n```"); } + /* + * A picture, a clip, or a PDF, shown as itself. The bytes come straight off + * /api/blobs — nothing is decoded into a string on the way, which is the whole + * point: the old path ran everything through the markdown renderer and a PNG + * came out as a screenful of mojibake. + */ + function paintPaneMedia() { + var body = $("paneBody"); + body.innerHTML = ""; + var kind = docKind(paneDoc); + var el; + if (kind === "image") { + el = document.createElement("img"); + el.alt = paneDoc.title || paneDoc.name; + el.src = blobUrl(paneDoc); + } else if (kind === "video" || kind === "audio") { + el = document.createElement(kind); + el.controls = true; + el.preload = "metadata"; + el.src = blobUrl(paneDoc); + } else { + // A PDF is a document the browser already knows how to read. The frame is + // plain (no sandbox attribute): these bytes are served without the + // `sandbox` CSP that HTML artifacts get, and a PDF carries no script for + // us to contain. + el = document.createElement("iframe"); + el.className = "pdfframe"; + el.title = paneDoc.title || paneDoc.name; + el.src = blobUrl(paneDoc); + } + el.classList.add("panemedia"); + // A file that won't decode (a codec the browser lacks, a truncated upload) + // shouldn't leave an empty pane with no explanation. + el.addEventListener("error", function () { + if (paneDoc) + paintPaneFile("This " + docNoun(paneDoc).toLowerCase() + " can't be shown here."); + }); + body.appendChild(el); + body.scrollTop = 0; + } + /* Nothing to show, so say what it is and offer the bytes. */ + function paintPaneFile(note) { + var body = $("paneBody"); + body.innerHTML = ""; + var box = document.createElement("div"); + box.className = "panefile"; + var name = document.createElement("div"); + name.className = "panefilename"; + name.textContent = paneDoc.name; + var sub = document.createElement("div"); + sub.className = "panefilenote"; + sub.textContent = note + (paneDoc.size ? " " + fmtBytes(paneDoc.size) + "." : ""); + var dl = document.createElement("button"); + dl.type = "button"; + dl.className = "btn primary"; + dl.textContent = "Download"; + dl.addEventListener("click", downloadDoc); + box.appendChild(name); + box.appendChild(sub); + box.appendChild(dl); + body.appendChild(box); + body.scrollTop = 0; + } function paintPaneDoc() { var body = $("paneBody"); body.innerHTML = ""; var text = paneDoc.content; + // The extension said text and the bytes disagree: show the file, not its + // decoding. + if (looksBinaryText(text)) { + paintPaneFile("This file isn't text, so there's nothing to render."); + return; + } if (isHtmlDoc(paneDoc)) { if (paneSource) renderHtmlSource(body, text); else renderHtmlDoc(body, text); @@ -1973,8 +2090,15 @@ import { mountSidebar } from "./sidebar.js"; ); items.push({ label: "Unpublish", danger: true, onClick: unpublishCurrent }); } - items.push({ label: "Download as Markdown", onClick: downloadMd }); - items.push({ label: "Print / Save as PDF", onClick: printPane }); + // "as Markdown" is only true of a markdown document; a chart or a clip is + // downloaded as what it is. + items.push({ + label: docKind(paneDoc) === "text" ? "Download as Markdown" : "Download", + onClick: downloadDoc, + }); + // Printing frames the pane's own rendering, which a media element doesn't + // really have — the browser's own viewer does that job better. + if (!isMediaDoc(paneDoc)) items.push({ label: "Print / Save as PDF", onClick: printPane }); return items; } function loadPaneVersions(doc) { @@ -2010,6 +2134,12 @@ import { mountSidebar } from "./sidebar.js"; // Switching revisions already has the history in hand; opening fresh doesn't. if (versions) paintPaneVersions(versions); else loadPaneVersions(doc); + // Media renders from its URL; only text is worth pulling into a string (and + // pulling a 40MB video into one would be worse than useless). + if (isMediaDoc(doc)) { + paintPaneMedia(); + return; + } var want = doc.sha256; fetch("/api/blobs/" + encodeURIComponent(doc.sha256)) .then(function (r) { @@ -2036,14 +2166,10 @@ import { mountSidebar } from "./sidebar.js"; } // The blob endpoint sets Content-Disposition from `?name`, so the browser // saves it under its real filename without the app re-encoding the bytes. - function downloadMd() { + function downloadDoc() { if (!paneDoc) return; var a = document.createElement("a"); - a.href = - "/api/blobs/" + - encodeURIComponent(paneDoc.sha256) + - "?name=" + - encodeURIComponent(paneDoc.name); + a.href = blobUrl(paneDoc); a.download = paneDoc.name; a.click(); } @@ -2184,6 +2310,12 @@ import { mountSidebar } from "./sidebar.js"; $("panePublic").addEventListener("click", copyPublicLink); $("paneCopy").addEventListener("click", function () { if (!paneDoc) return; + // Copy means the text. A picture or a clip has none, so the split button's + // primary action becomes the one that makes sense for it. + if (isMediaDoc(paneDoc) || typeof paneDoc.content !== "string") { + downloadDoc(); + return; + } var btn = $("paneCopy"); navigator.clipboard.writeText(paneDoc.content).then(function () { btn.textContent = "Copied"; @@ -2226,8 +2358,7 @@ import { mountSidebar } from "./sidebar.js"; kind.className = "artifactkind"; // Version only once there IS history — "v1" on a document written once is // noise about a thing that hasn't happened. - kind.textContent = - (isHtmlDoc(ref) ? "Page" : "Document") + (ref.version > 1 ? " \u00b7 v" + ref.version : ""); + kind.textContent = docNoun(ref) + (ref.version > 1 ? " \u00b7 v" + ref.version : ""); text.appendChild(h); text.appendChild(kind); @@ -2235,26 +2366,49 @@ import { mountSidebar } from "./sidebar.js"; // enough to read. Deliberately not an icon: the preview IS the content. var peek = document.createElement("span"); peek.className = "artifactpeek"; - var paper = document.createElement("span"); - paper.className = "artifactpaper"; - peek.appendChild(paper); - // The peek is meant to be recognizable prose, and a page's opening bytes are - // `