Add "Share link" to copy a shareable URL to the current figure
Encodes the open figure into a self-link (…#fig=<gzip+base64>) and copies it to
the clipboard; if the figure is too large for a URL, shows a toast saying it
can't be shared via link.
2 changed files+66−0
src/App.tsxmodified+38−0View file
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import {
33 importFigureHdf5,
44 loadFigureFromHash,
5+ buildFigureViewerLink,
56 FigureView,
67 type FigureState,
78 } from "numbl/graphics";
@@ -52,6 +53,8 @@ export function App() {
5253 const [selectedId, setSelectedId] = useState<string | null>(null);
5354 const [viewState, setViewState] = useState<ViewState>(emptyViewState);
5455 const [dragOver, setDragOver] = useState(false);
56+ const [notice, setNotice] = useState<string | null>(null);
57+ const noticeTimer = useRef<number | undefined>(undefined);
5558
5659 const isMobile = useMediaQuery("(max-width: 820px)");
5760 const [leftOpen, setLeftOpen] = useState(true);
@@ -144,6 +147,30 @@ export function App() {
144147 if (isMobile) setLeftOpen(false);
145148 };
146149
150+ const showNotice = useCallback((msg: string) => {
151+ setNotice(msg);
152+ if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
153+ noticeTimer.current = window.setTimeout(() => setNotice(null), 2800);
154+ }, []);
155+
156+ // Share the current figure as a link: encode it into a URL pointing back at
157+ // this viewer and copy it to the clipboard, unless it's too large for a URL.
158+ const handleShare = useCallback(async () => {
159+ if (!figure) return;
160+ const base = window.location.origin + window.location.pathname;
161+ const link = buildFigureViewerLink(figure, base);
162+ if (!link.url) {
163+ showNotice("This figure is too large to share via a link.");
164+ return;
165+ }
166+ try {
167+ await navigator.clipboard.writeText(link.url);
168+ showNotice("Link copied to clipboard");
169+ } catch {
170+ showNotice("Couldn’t copy the link to the clipboard.");
171+ }
172+ }, [figure, showNotice]);
173+
147174 const fileInput = (
148175 <input
149176 ref={inputRef}
@@ -189,6 +216,15 @@ export function App() {
189216 <span className="app-title">numbl figure viewer</span>
190217 {figure && <span className="file-name">{fileName}</span>}
191218 <span className="spacer" />
219+ {figure && (
220+ <button
221+ className="text-btn"
222+ onClick={handleShare}
223+ title="Copy a shareable link to this figure"
224+ >
225+ Share link
226+ </button>
227+ )}
192228 <button className="text-btn" onClick={pickFile} disabled={loading}>
193229 {loading ? "Loading…" : "Open .h5…"}
194230 </button>
@@ -331,6 +367,8 @@ export function App() {
331367 )}
332368 </div>
333369 )}
370+
371+ {notice && <div className="toast">{notice}</div>}
334372 </div>
335373 );
336374 }
src/index.cssmodified+28−0View file
@@ -520,6 +520,34 @@ td.pv {
520520 z-index: 10;
521521 }
522522
523+/* ── Toast ─────────────────────────────────────────────────────────────── */
524+.toast {
525+ position: fixed;
526+ left: 50%;
527+ bottom: 28px;
528+ transform: translateX(-50%);
529+ z-index: 50;
530+ max-width: 90vw;
531+ padding: 9px 16px;
532+ border-radius: 8px;
533+ background: #2b2b2b;
534+ color: #fff;
535+ font-size: 13px;
536+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
537+ animation: toast-in 0.15s ease-out;
538+}
539+
540+@keyframes toast-in {
541+ from {
542+ opacity: 0;
543+ transform: translate(-50%, 6px);
544+ }
545+ to {
546+ opacity: 1;
547+ transform: translate(-50%, 0);
548+ }
549+}
550+
523551 /* Touch / narrow screens: no hover, so always show the row controls. */
524552 @media (max-width: 820px) {
525553 .row-actions {