Guest User

Untitled

a guest
Aug 5th, 2026
41
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 14.90 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Neuroglancer Copy Completion Row
  3. // @namespace eyewire-elite
  4. // @version 2.0.0
  5. // @description Copies completion date, selected segment ID, and a clickable Neuroglancer link for Google Sheets.
  6. // @match https://spelunker.cave-explorer.org/*
  7. // @run-at document-idle
  8. // @grant GM_setClipboard
  9. // ==/UserScript==
  10.  
  11. (function () {
  12. 'use strict';
  13.  
  14. const CONFIG = Object.freeze({
  15. buttonId: 'nseraf-copy-completion-widget',
  16.  
  17. positionStorageKey:
  18. 'nseraf-copy-completion-widget-position-v1',
  19.  
  20. defaultPosition: {
  21. top: 75,
  22. right: 20
  23. },
  24.  
  25. dragThresholdPixels: 4
  26. });
  27.  
  28. let dragState = null;
  29.  
  30. installWidget();
  31.  
  32.  
  33. // ==========================================================================
  34. // DRAGGABLE COPY BUTTON
  35. // ==========================================================================
  36.  
  37. function installWidget() {
  38. if (document.getElementById(CONFIG.buttonId)) {
  39. return;
  40. }
  41.  
  42. const widget = document.createElement('div');
  43.  
  44. widget.id = CONFIG.buttonId;
  45.  
  46. widget.title =
  47. 'Drag to reposition. Click to copy completion data.';
  48.  
  49. Object.assign(widget.style, {
  50. position: 'fixed',
  51. zIndex: '2147483647',
  52. display: 'flex',
  53. flexDirection: 'column',
  54. alignItems: 'stretch',
  55. minWidth: '170px',
  56. maxWidth: '310px',
  57. fontFamily: 'Arial, Helvetica, sans-serif',
  58. userSelect: 'none',
  59. touchAction: 'none'
  60. });
  61.  
  62. const button = document.createElement('button');
  63.  
  64. button.type = 'button';
  65. button.textContent = 'Copy completion';
  66.  
  67. Object.assign(button.style, {
  68. border: '1px solid rgba(255, 255, 255, 0.65)',
  69. borderRadius: '7px',
  70. padding: '9px 13px',
  71. background: '#1769aa',
  72. color: '#ffffff',
  73. fontSize: '13px',
  74. fontWeight: 'bold',
  75. cursor: 'grab',
  76. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.45)'
  77. });
  78.  
  79. const message = document.createElement('div');
  80.  
  81. Object.assign(message.style, {
  82. display: 'none',
  83. marginTop: '5px',
  84. padding: '7px 9px',
  85. borderRadius: '6px',
  86. background: 'rgba(0, 0, 0, 0.88)',
  87. color: '#ffffff',
  88. fontSize: '12px',
  89. lineHeight: '1.35',
  90. overflowWrap: 'anywhere',
  91. whiteSpace: 'pre-wrap'
  92. });
  93.  
  94. widget.appendChild(button);
  95. widget.appendChild(message);
  96.  
  97. document.documentElement.appendChild(widget);
  98.  
  99. restorePosition(widget);
  100. keepWidgetOnScreen(widget);
  101.  
  102. button.addEventListener(
  103. 'pointerdown',
  104. function (event) {
  105. beginDrag(event, widget, button);
  106. }
  107. );
  108.  
  109. button.addEventListener(
  110. 'pointermove',
  111. function (event) {
  112. continueDrag(event, widget);
  113. }
  114. );
  115.  
  116. button.addEventListener(
  117. 'pointerup',
  118. function (event) {
  119. finishDrag(
  120. event,
  121. widget,
  122. button,
  123. message
  124. );
  125. }
  126. );
  127.  
  128. button.addEventListener(
  129. 'pointercancel',
  130. function () {
  131. cancelDrag(button);
  132. }
  133. );
  134.  
  135. window.addEventListener(
  136. 'resize',
  137. function () {
  138. keepWidgetOnScreen(widget);
  139. savePosition(widget);
  140. }
  141. );
  142. }
  143.  
  144.  
  145. function beginDrag(event, widget, button) {
  146. if (
  147. event.pointerType === 'mouse' &&
  148. event.button !== 0
  149. ) {
  150. return;
  151. }
  152.  
  153. const rectangle =
  154. widget.getBoundingClientRect();
  155.  
  156. dragState = {
  157. pointerId: event.pointerId,
  158. startPointerX: event.clientX,
  159. startPointerY: event.clientY,
  160. startWidgetLeft: rectangle.left,
  161. startWidgetTop: rectangle.top,
  162. moved: false
  163. };
  164.  
  165. button.setPointerCapture(
  166. event.pointerId
  167. );
  168.  
  169. button.style.cursor = 'grabbing';
  170.  
  171. event.preventDefault();
  172. }
  173.  
  174.  
  175. function continueDrag(event, widget) {
  176. if (
  177. !dragState ||
  178. event.pointerId !== dragState.pointerId
  179. ) {
  180. return;
  181. }
  182.  
  183. const deltaX =
  184. event.clientX -
  185. dragState.startPointerX;
  186.  
  187. const deltaY =
  188. event.clientY -
  189. dragState.startPointerY;
  190.  
  191. if (
  192. Math.abs(deltaX) >=
  193. CONFIG.dragThresholdPixels ||
  194. Math.abs(deltaY) >=
  195. CONFIG.dragThresholdPixels
  196. ) {
  197. dragState.moved = true;
  198. }
  199.  
  200. if (!dragState.moved) {
  201. return;
  202. }
  203.  
  204. const rectangle =
  205. widget.getBoundingClientRect();
  206.  
  207. const maximumLeft = Math.max(
  208. 0,
  209. window.innerWidth - rectangle.width
  210. );
  211.  
  212. const maximumTop = Math.max(
  213. 0,
  214. window.innerHeight - rectangle.height
  215. );
  216.  
  217. const nextLeft = clamp(
  218. dragState.startWidgetLeft + deltaX,
  219. 0,
  220. maximumLeft
  221. );
  222.  
  223. const nextTop = clamp(
  224. dragState.startWidgetTop + deltaY,
  225. 0,
  226. maximumTop
  227. );
  228.  
  229. widget.style.left =
  230. nextLeft + 'px';
  231.  
  232. widget.style.top =
  233. nextTop + 'px';
  234.  
  235. widget.style.right = 'auto';
  236. widget.style.bottom = 'auto';
  237.  
  238. event.preventDefault();
  239. }
  240.  
  241.  
  242. function finishDrag(
  243. event,
  244. widget,
  245. button,
  246. message
  247. ) {
  248. if (
  249. !dragState ||
  250. event.pointerId !== dragState.pointerId
  251. ) {
  252. return;
  253. }
  254.  
  255. const wasDragged =
  256. dragState.moved;
  257.  
  258. try {
  259. button.releasePointerCapture(
  260. event.pointerId
  261. );
  262. } catch (error) {
  263. // Pointer capture may already be released.
  264. }
  265.  
  266. dragState = null;
  267. button.style.cursor = 'grab';
  268.  
  269. if (wasDragged) {
  270. keepWidgetOnScreen(widget);
  271. savePosition(widget);
  272.  
  273. showMessage(
  274. message,
  275. 'Button position saved.',
  276. 'success'
  277. );
  278. } else {
  279. copyCompletionData(
  280. button,
  281. message
  282. );
  283. }
  284.  
  285. event.preventDefault();
  286. }
  287.  
  288.  
  289. function cancelDrag(button) {
  290. dragState = null;
  291. button.style.cursor = 'grab';
  292. }
  293.  
  294.  
  295. // ==========================================================================
  296. // COPY COMPLETION DATA
  297. // ==========================================================================
  298.  
  299. async function copyCompletionData(
  300. button,
  301. message
  302. ) {
  303. const originalText =
  304. button.textContent;
  305.  
  306. try {
  307. button.disabled = true;
  308. button.textContent =
  309. 'Reading viewer…';
  310.  
  311. const finalUrl =
  312. window.location.href;
  313.  
  314. const segmentIds =
  315. extractSelectedSegmentIds(
  316. finalUrl
  317. );
  318.  
  319. if (segmentIds.length === 0) {
  320. throw new Error(
  321. 'No selected segment was found in the current Neuroglancer URL.'
  322. );
  323. }
  324.  
  325. if (segmentIds.length > 1) {
  326. throw new Error(
  327. 'Multiple selected segments were found: ' +
  328. segmentIds.join(', ') +
  329. '. Leave only the final segment selected and try again.'
  330. );
  331. }
  332.  
  333. const completionDate =
  334. formatLocalDate(
  335. new Date()
  336. );
  337.  
  338. const finalSegmentId =
  339. segmentIds[0];
  340.  
  341. /*
  342. * Escape quotation marks so the URL can safely be placed inside a
  343. * Google Sheets HYPERLINK formula.
  344. */
  345. const safeFinalUrl =
  346. finalUrl.replace(
  347. /"/g,
  348. '""'
  349. );
  350.  
  351. /*
  352. * Column M becomes a clickable hyperlink displayed as:
  353. *
  354. * https://spelunker
  355. */
  356. const hyperlinkFormula =
  357. '=HYPERLINK("' +
  358. safeFinalUrl +
  359. '","https://spelunker")';
  360.  
  361. /*
  362. * Tab-separated values paste into adjacent Google Sheets columns:
  363. *
  364. * K = Date Complete
  365. * L = Final SegID
  366. * M = Final Link
  367. */
  368. const clipboardText = [
  369. completionDate,
  370. finalSegmentId,
  371. hyperlinkFormula
  372. ].join('\t');
  373.  
  374. button.textContent =
  375. 'Copying…';
  376.  
  377. await copyTextToClipboard(
  378. clipboardText
  379. );
  380.  
  381. button.textContent =
  382. 'Copied ✓';
  383.  
  384. showMessage(
  385. message,
  386. 'Copied:\n' +
  387. completionDate +
  388. ' | ' +
  389. finalSegmentId +
  390. '\nPaste into the row’s column K cell.',
  391. 'success'
  392. );
  393. } catch (error) {
  394. console.error(
  395. '[Completion script] Copy failed:',
  396. error
  397. );
  398.  
  399. button.textContent =
  400. 'Copy failed';
  401.  
  402. showMessage(
  403. message,
  404. error && error.message
  405. ? error.message
  406. : String(error),
  407. 'error'
  408. );
  409. } finally {
  410. window.setTimeout(
  411. function () {
  412. button.disabled = false;
  413. button.textContent =
  414. originalText;
  415. },
  416. 1800
  417. );
  418. }
  419. }
  420.  
  421.  
  422. /**
  423. * Extracts all selected segmentation IDs from the current URL state.
  424. */
  425. function extractSelectedSegmentIds(
  426. url
  427. ) {
  428. const marker = '#!';
  429.  
  430. const markerIndex =
  431. url.indexOf(marker);
  432.  
  433. if (markerIndex === -1) {
  434. throw new Error(
  435. 'The URL does not contain a Neuroglancer #! viewer state.'
  436. );
  437. }
  438.  
  439. const encodedState =
  440. url.substring(
  441. markerIndex +
  442. marker.length
  443. );
  444.  
  445. let decodedState;
  446.  
  447. try {
  448. decodedState =
  449. decodeURIComponent(
  450. encodedState
  451. );
  452. } catch (error) {
  453. throw new Error(
  454. 'The Neuroglancer URL could not be decoded.'
  455. );
  456. }
  457.  
  458. let viewerState;
  459.  
  460. try {
  461. viewerState =
  462. JSON.parse(
  463. decodedState
  464. );
  465. } catch (error) {
  466. throw new Error(
  467. 'The decoded Neuroglancer state is not valid JSON.'
  468. );
  469. }
  470.  
  471. if (
  472. !Array.isArray(
  473. viewerState.layers
  474. )
  475. ) {
  476. throw new Error(
  477. 'The Neuroglancer state does not contain a layer list.'
  478. );
  479. }
  480.  
  481. const segmentIds = [];
  482.  
  483. viewerState.layers.forEach(
  484. function (layer) {
  485. if (
  486. !layer ||
  487. layer.type !==
  488. 'segmentation' ||
  489. !Array.isArray(
  490. layer.segments
  491. )
  492. ) {
  493. return;
  494. }
  495.  
  496. layer.segments.forEach(
  497. function (segmentId) {
  498. const value =
  499. String(
  500. segmentId
  501. ).trim();
  502.  
  503. if (/^\d+$/.test(value)) {
  504. segmentIds.push(value);
  505. }
  506. }
  507. );
  508. }
  509. );
  510.  
  511. return Array.from(
  512. new Set(segmentIds)
  513. );
  514. }
  515.  
  516.  
  517. /**
  518. * Produces dates such as 8/5/2026 using the computer's local date.
  519. */
  520. function formatLocalDate(date) {
  521. return [
  522. date.getMonth() + 1,
  523. date.getDate(),
  524. date.getFullYear()
  525. ].join('/');
  526. }
  527.  
  528.  
  529. async function copyTextToClipboard(
  530. text
  531. ) {
  532. if (
  533. typeof GM_setClipboard ===
  534. 'function'
  535. ) {
  536. GM_setClipboard(
  537. text,
  538. 'text'
  539. );
  540.  
  541. return;
  542. }
  543.  
  544. if (
  545. navigator.clipboard &&
  546. typeof navigator.clipboard
  547. .writeText === 'function'
  548. ) {
  549. await navigator.clipboard
  550. .writeText(text);
  551.  
  552. return;
  553. }
  554.  
  555. const temporaryTextArea =
  556. document.createElement(
  557. 'textarea'
  558. );
  559.  
  560. temporaryTextArea.value =
  561. text;
  562.  
  563. temporaryTextArea.setAttribute(
  564. 'readonly',
  565. ''
  566. );
  567.  
  568. Object.assign(
  569. temporaryTextArea.style,
  570. {
  571. position: 'fixed',
  572. left: '-9999px',
  573. top: '0'
  574. }
  575. );
  576.  
  577. document.body.appendChild(
  578. temporaryTextArea
  579. );
  580.  
  581. temporaryTextArea.select();
  582.  
  583. const copied =
  584. document.execCommand(
  585. 'copy'
  586. );
  587.  
  588. temporaryTextArea.remove();
  589.  
  590. if (!copied) {
  591. throw new Error(
  592. 'The browser refused clipboard access.'
  593. );
  594. }
  595. }
  596.  
  597.  
  598. // ==========================================================================
  599. // SAVED BUTTON POSITION
  600. // ==========================================================================
  601.  
  602. function savePosition(widget) {
  603. const rectangle =
  604. widget.getBoundingClientRect();
  605.  
  606. const position = {
  607. left: Math.round(
  608. rectangle.left
  609. ),
  610. top: Math.round(
  611. rectangle.top
  612. )
  613. };
  614.  
  615. try {
  616. localStorage.setItem(
  617. CONFIG.positionStorageKey,
  618. JSON.stringify(position)
  619. );
  620. } catch (error) {
  621. console.warn(
  622. '[Completion script] Could not save button position:',
  623. error
  624. );
  625. }
  626. }
  627.  
  628.  
  629. function restorePosition(widget) {
  630. let restoredPosition = null;
  631.  
  632. try {
  633. const storedValue =
  634. localStorage.getItem(
  635. CONFIG.positionStorageKey
  636. );
  637.  
  638. if (storedValue) {
  639. restoredPosition =
  640. JSON.parse(storedValue);
  641. }
  642. } catch (error) {
  643. console.warn(
  644. '[Completion script] Could not restore button position:',
  645. error
  646. );
  647. }
  648.  
  649. if (
  650. restoredPosition &&
  651. Number.isFinite(
  652. restoredPosition.left
  653. ) &&
  654. Number.isFinite(
  655. restoredPosition.top
  656. )
  657. ) {
  658. widget.style.left =
  659. restoredPosition.left +
  660. 'px';
  661.  
  662. widget.style.top =
  663. restoredPosition.top +
  664. 'px';
  665.  
  666. widget.style.right =
  667. 'auto';
  668.  
  669. return;
  670. }
  671.  
  672. widget.style.top =
  673. CONFIG.defaultPosition.top +
  674. 'px';
  675.  
  676. widget.style.right =
  677. CONFIG.defaultPosition.right +
  678. 'px';
  679. }
  680.  
  681.  
  682. function keepWidgetOnScreen(widget) {
  683. const rectangle =
  684. widget.getBoundingClientRect();
  685.  
  686. const maximumLeft = Math.max(
  687. 0,
  688. window.innerWidth -
  689. rectangle.width
  690. );
  691.  
  692. const maximumTop = Math.max(
  693. 0,
  694. window.innerHeight -
  695. rectangle.height
  696. );
  697.  
  698. const nextLeft = clamp(
  699. rectangle.left,
  700. 0,
  701. maximumLeft
  702. );
  703.  
  704. const nextTop = clamp(
  705. rectangle.top,
  706. 0,
  707. maximumTop
  708. );
  709.  
  710. widget.style.left =
  711. nextLeft + 'px';
  712.  
  713. widget.style.top =
  714. nextTop + 'px';
  715.  
  716. widget.style.right =
  717. 'auto';
  718.  
  719. widget.style.bottom =
  720. 'auto';
  721. }
  722.  
  723.  
  724. function showMessage(
  725. messageElement,
  726. text,
  727. type
  728. ) {
  729. messageElement.textContent =
  730. text;
  731.  
  732. messageElement.style.background =
  733. type === 'error'
  734. ? 'rgba(165, 20, 20, 0.95)'
  735. : 'rgba(0, 105, 45, 0.95)';
  736.  
  737. messageElement.style.display =
  738. 'block';
  739.  
  740. window.clearTimeout(
  741. showMessage.timeoutId
  742. );
  743.  
  744. showMessage.timeoutId =
  745. window.setTimeout(
  746. function () {
  747. messageElement.style.display =
  748. 'none';
  749. },
  750. 6000
  751. );
  752. }
  753.  
  754.  
  755. function clamp(
  756. value,
  757. minimum,
  758. maximum
  759. ) {
  760. return Math.min(
  761. Math.max(
  762. value,
  763. minimum
  764. ),
  765. maximum
  766. );
  767. }
  768. })();
Advertisement
Add Comment
Please, Sign In to add comment