last position bookmark for Civitai

2024/8/25 15:02:30

  1. // ==UserScript==
  2. // @name last position bookmark for Civitai
  3. // @namespace Violentmonkey Scripts
  4. // @match https://civitai.com/*
  5. // @grant GM_registerMenuCommand
  6. // @grant GM_unregisterMenuCommand
  7. // @grant GM_addStyle
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @run-at document-idle
  11. // @version 1.5.5
  12. // @license MIT
  13. // @description 2024/8/25 15:02:30
  14. // ==/UserScript==
  15.  
  16. const LOCAL_STORAGE_KEY = 'bookmarks';
  17. const BOOKMARK_MODEL_SIZE = 5;
  18. const LOAD_NEXT_PAGE = 2000;
  19. const BOOKMARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="currentColor">< !--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M0 48V487.7C0 501.1 10.9 512 24.3 512c5 0 9.9-1.5 14-4.4L192 400 345.7 507.6c4.1 2.9 9 4.4 14 4.4c13.4 0 24.3-10.9 24.3-24.3V48c0-26.5-21.5-48-48-48H48C21.5 0 0 21.5 0 48z"/></svg>`;
  20. const BOOKMARK_CLASSNAME = 'bookmarked';
  21. const JUMP_TO_BOOKMARK_BUTTON_ID_NAME = 'jump-to-bookmark';
  22. const MESSAGE_CONTAINER_CLASSNAME = 'message-container';
  23. const LOOKING_FOR_THE_BOOKMARK = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <circle cx="18" cy="12" r="0" fill="currentColor"> <animate attributeName="r" begin=".67" calcMode="spline" dur="1.5s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0" /> </circle> <circle cx="12" cy="12" r="0" fill="currentColor"> <animate attributeName="r" begin=".33" calcMode="spline" dur="1.5s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0" /> </circle> <circle cx="6" cy="12" r="0" fill="currentColor"> <animate attributeName="r" begin="0" calcMode="spline" dur="1.5s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0" /> </circle> </svg>`
  24. const LOOKING_FOR_THE_BOOKMARK_CLASSNAME = 'looking-for-the-bookmark'
  25.  
  26. let isHideEarlyAccessEnabled = GM_getValue("isHideEarlyAccessEnabled", false);
  27.  
  28. GM_addStyle(`
  29. @keyframes pulse {
  30. 0% {
  31. background-color: gold;
  32. opacity: 1;
  33. }
  34. 50% {
  35. background-color: coral;
  36. opacity: 1;
  37. }
  38. 100% {
  39. background-color: gold;
  40. opacity: 1;
  41. }
  42. }
  43.  
  44. .bookmarked {
  45. border: 6px solid coral !important;
  46. }
  47.  
  48. .bookmark-icon {
  49. position:absolute;
  50. right: 30px;
  51. top: -5px;
  52. height: 60px;
  53. width: 24px;
  54. color: coral;
  55. z-index: 9;
  56. }
  57.  
  58. .scroll-to-bookmark-button {
  59. display: flex;
  60. gap: 1rem;
  61. align-items: center;
  62. position: fixed;
  63. bottom: 40px;
  64. right: -14rem;
  65. color: #EFEFEF;
  66. background-color: coral;
  67. padding: 0.5rem 2.5rem 0.5rem 0.8rem;
  68. border-radius: 2rem;
  69. border: 2px solid #EFEFEF;
  70. z-index: 100;
  71. transition: all 0.1s ease-out;
  72. }
  73. .scroll-to-bookmark-button:hover {
  74. right: -1.5rem;
  75. }
  76. .scroll-to-bookmark-button.active {
  77. right: -1.5rem;
  78. animation: 2s ease-in-out infinite pulse ;
  79. }
  80. .scroll-to-bookmark-button.hide {
  81. display: none;
  82. }
  83.  
  84.  
  85. .message-container {
  86. position: absolute;
  87. display: flex;
  88. justify-content: center;
  89. align-items: center;
  90. width: 100%;
  91. top: 0;
  92. left: 0;
  93. z-index: 1000;
  94. }
  95.  
  96. .looking-for-the-bookmark {
  97. position: fixed;
  98. top: 2rem;
  99. width:auto;
  100. overflow: hidden;
  101. color: #EFEFEF;
  102. background-color: coral;
  103. transition: all 0.1s ease-out;
  104. padding: 0.3rem 0.9rem;
  105. border-radius: 2rem;
  106. display: flex;
  107. border: 2px solid #EFEFEF;
  108. opacity: 1;
  109. }
  110.  
  111. .looking-for-the-bookmark.opacity0 {
  112. opacity: 0;
  113. }
  114. `)
  115.  
  116. 'use strict;'
  117.  
  118. function $(selector) {
  119. return document.querySelector(selector)
  120. }
  121.  
  122. function $$(selector) {
  123. return document.querySelectorAll(selector)
  124. }
  125.  
  126. function sleep(ms = 1000) {
  127. return new Promise((resolve) => {
  128. setTimeout(() => {
  129. resolve(true);
  130. }, ms);
  131. })
  132. }
  133.  
  134. function addAttribute(elem, key, value) {
  135. const oldValues = elem.getAttribute(key);
  136. elem.setAttribute(key, `${oldValues} ${value}`);
  137. }
  138.  
  139. async function isSortByNewest() {
  140. await sleep();
  141. const divs = Array.from(document.querySelectorAll('div')).filter(x => x.innerText === 'Newest');
  142. return divs.length > 0;
  143. }
  144.  
  145. function queryAllModels() {
  146. return Array.from(document.querySelectorAll('a[href^="/models/"]:not([data-unread])'));
  147. }
  148.  
  149. function createBookmarkIcon() {
  150. const div = document.createElement('div');
  151. div.innerHTML = BOOKMARK_SVG;
  152. return div;
  153. }
  154.  
  155. function addLookingForTheBookmarkMessage() {
  156. const container = document.createElement('div');
  157. container.classList.add(MESSAGE_CONTAINER_CLASSNAME)
  158. container.classList.add('hidden')
  159. const div = document.createElement('div');
  160. div.classList.add(LOOKING_FOR_THE_BOOKMARK_CLASSNAME)
  161. div.classList.add('opacity0')
  162. container.appendChild(div);
  163. $('body').appendChild(container);
  164. }
  165.  
  166. function showLookingForTheBookmarkMessage() {
  167. $(`.${MESSAGE_CONTAINER_CLASSNAME}`).classList.remove('hidden')
  168. $(`.${LOOKING_FOR_THE_BOOKMARK_CLASSNAME}`).innerHTML = `looking for the last bookmark${LOOKING_FOR_THE_BOOKMARK}`;
  169. $(`.${LOOKING_FOR_THE_BOOKMARK_CLASSNAME}`).classList.remove('opacity0')
  170. }
  171.  
  172. function updateLookingForTheBookmarkMessage(pageNumber) {
  173. $(`.${LOOKING_FOR_THE_BOOKMARK_CLASSNAME}`).innerHTML = `looking for the last bookmark${LOOKING_FOR_THE_BOOKMARK}${pageNumber}`;
  174. }
  175.  
  176. function hideLookingForTheBookmarkMessage(updateMessage = '') {
  177. if (updateMessage) {
  178. $(`.${LOOKING_FOR_THE_BOOKMARK_CLASSNAME}`).innerHTML = updateMessage;
  179. }
  180. $(`.${LOOKING_FOR_THE_BOOKMARK_CLASSNAME}`).classList.add('opacity0')
  181. setTimeout(() => {
  182. $(`.${MESSAGE_CONTAINER_CLASSNAME}`).classList.add('hidden')
  183. }, 100)
  184. }
  185.  
  186. async function waitForLoadingComplete(retry = 1) {
  187. if (retry > 10) {
  188. console.error('wait for page loading timeout. nothing happened.');
  189. return;
  190. }
  191.  
  192. await sleep(500);
  193. const models = queryAllModels();
  194.  
  195. if (models.length === 0) {
  196. return waitForLoadingComplete(retry + 1);
  197. }
  198. }
  199.  
  200. async function findAndMarkBookmarkedModel(bookmarks) {
  201. await waitForLoadingComplete();
  202. const models = queryAllModels();
  203. const bookmarkedModels = models.filter((model) => {
  204. return bookmarks.some(bookmark => model.href.match(bookmark))
  205. }).filter(x => !x.innerText.includes('Early Access'));
  206. const bookmarkedModel = bookmarkedModels.shift() ?? null;
  207. if (!bookmarkedModel) {
  208. return [null, models.pop(), models.slice(0, BOOKMARK_MODEL_SIZE)];
  209. }
  210. const icon = createBookmarkIcon()
  211. icon.classList.add('bookmark-icon')
  212.  
  213. bookmarkedModel.parentNode.parentNode.appendChild(icon);
  214. bookmarkedModel.parentNode.parentNode.classList.add(BOOKMARK_CLASSNAME);
  215.  
  216. return [bookmarkedModel, null, models.slice(0, BOOKMARK_MODEL_SIZE)];
  217. }
  218.  
  219. function loadBookmarks() {
  220. return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY));
  221. }
  222.  
  223. function activateButton(retry = 1) {
  224. if (retry >= 5) {
  225. console.log('cannot find activate button in 2.5secs')
  226. return;
  227. }
  228. setTimeout(() => {
  229. if (ifBookmarkIsInView()) {
  230. return;
  231. }
  232. const button = $(`#${JUMP_TO_BOOKMARK_BUTTON_ID_NAME}`);
  233. if (!button) {
  234. activateButton(retry + 1)
  235. return
  236. }
  237. button.classList.add('active')
  238. }, 500)
  239. }
  240.  
  241.  
  242. // function scrollToBottom() {
  243. // const height = $('main').getBoundingClientRect().height ?? j0
  244. // const scrollArea = $('.scroll-area');
  245. // scrollArea.scrollTop = scrollArea.scrollHeight;
  246. // }
  247.  
  248. async function initialScrollToTheBookmark(retry = 1, newestModels = null) {
  249. await waitForLoadingComplete();
  250. const bookmarks = loadBookmarks();
  251.  
  252. if (!bookmarks) {
  253. await saveBookmark();
  254. return;
  255. }
  256.  
  257. showLookingForTheBookmarkMessage()
  258. updateLookingForTheBookmarkMessage(retry)
  259.  
  260. if (retry > LOAD_NEXT_PAGE) {
  261. localStorage.removeItem(LOCAL_STORAGE_KEY);
  262. console.log('could nod find bookmarks')
  263. await saveBookmark(newestModels);
  264. hideLookingForTheBookmarkMessage('could not find the last bookmark')
  265. return;
  266. }
  267.  
  268. if ($(`.${BOOKMARK_CLASSNAME}`)) {
  269. $(`.${BOOKMARK_CLASSNAME}`).scrollIntoView({ behavior: 'smooth' });
  270. return
  271. }
  272.  
  273. const [bookmarkedModel, oldestModel, _newestModels] = await findAndMarkBookmarkedModel(bookmarks);
  274. if (!bookmarkedModel) {
  275. console.info('the bookmarked model is not found in this page', retry);
  276. oldestModel.scrollIntoView();
  277. await sleep(1000);
  278. return await initialScrollToTheBookmark(retry + 1, retry === 1 ? _newestModels : newestModels);
  279. }
  280.  
  281. hideLookingForTheBookmarkMessage('found the last bookmark')
  282.  
  283. bookmarkedModel.scrollIntoView({ behavior: 'smooth' });
  284. activateButton()
  285. await saveBookmark(newestModels);
  286.  
  287. console.info('moved to the last bookmark');
  288.  
  289. return;
  290. }
  291.  
  292. async function saveBookmark(newestModels = null) {
  293. await waitForLoadingComplete();
  294.  
  295. const modelsForBookmark = newestModels ? newestModels : queryAllModels().slice(0, BOOKMARK_MODEL_SIZE);
  296. const bookmarks = modelsForBookmark.map(x => {
  297. const modelId = x?.href?.match(/models\/(\d*)\//)?.at(1) ?? 'none';
  298. const modelVersionId = x?.href?.match(/\?modelVersionId=(\d*)/)?.at(1) ?? '';
  299. return `${modelId}.*${modelVersionId}`
  300. })
  301.  
  302. if (bookmarks.length === 0) {
  303. console.error('there are no models: ', modelsForBookmark);
  304. return
  305. }
  306. localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(bookmarks));
  307. }
  308.  
  309. function forceMoveToBookmark() {
  310. $(`.${BOOKMARK_CLASSNAME}`).scrollIntoView({ behavior: 'smooth' });
  311. }
  312.  
  313. function addScrollToBookmarkButton() {
  314. if ($(`#${JUMP_TO_BOOKMARK_BUTTON_ID_NAME}`)) {
  315. return;
  316. }
  317.  
  318. const icon = createBookmarkIcon();
  319. icon.setAttribute('style', 'width: 12px;')
  320.  
  321. const button = document.createElement('button');
  322. button.id = JUMP_TO_BOOKMARK_BUTTON_ID_NAME;
  323. button.classList.add('hidden')
  324. button.classList.add('scroll-to-bookmark-button')
  325. button.innerHTML = `<p>jump to the bookmark</p>`;
  326. button.prepend(icon)
  327. button.addEventListener('click', () => {
  328. button.classList.remove('active')
  329. forceMoveToBookmark();
  330. });
  331.  
  332. $('body').appendChild(button);
  333. }
  334.  
  335. function ifBookmarkIsInView() {
  336. const bookmarkedElement = $(`.${BOOKMARK_CLASSNAME}`);
  337. if (!bookmarkedElement) {
  338. return false;
  339. }
  340.  
  341. const innerHeight = window.innerHeight;
  342. const { top, bottom } = bookmarkedElement.getBoundingClientRect();
  343.  
  344. if ((top >= 0 && top <= innerHeight) || (bottom >= 0 && bottom <= innerHeight)) {
  345. return true;
  346. }
  347.  
  348. return false;
  349. }
  350.  
  351. function hideEarlyAccess() {
  352. if (isHideEarlyAccessEnabled) {
  353. $$('a[href^="/models/"]').forEach(x => {
  354. if (x.parentNode.innerText.includes('Early Access')) {
  355. x.parentNode.setAttribute('style', 'opacity: 0;')
  356. }
  357. })
  358. }
  359. }
  360.  
  361. function registerMenuToggleHideEarlyAccess() {
  362. let currentCommandId;
  363. function updateMenu() {
  364. if (currentCommandId) {
  365. GM_unregisterMenuCommand(currentCommandId);
  366. }
  367. const label = isHideEarlyAccessEnabled ? '☑ hide Early Access' : '□ hide Early Access';
  368. currentCommandId = GM_registerMenuCommand(label, toggleFeature);
  369. }
  370. function toggleFeature() {
  371. isHideEarlyAccessEnabled = !isHideEarlyAccessEnabled;
  372. GM_setValue("isHideEarlyAccessEnabled", isHideEarlyAccessEnabled); // 状態を保存
  373. updateMenu();
  374. }
  375. updateMenu();
  376. }
  377.  
  378. let prevLocation = window.location.href;
  379. function observeLocationChange() {
  380. const observer = new MutationObserver(async () => {
  381. const currentLocation = window.location.href;
  382. if (prevLocation !== currentLocation) {
  383. prevLocation = currentLocation
  384. if (currentLocation.endsWith('models')) {
  385. const bookmarks = loadBookmarks();
  386. await findAndMarkBookmarkedModel(bookmarks)
  387. $(`#${JUMP_TO_BOOKMARK_BUTTON_ID_NAME}`).classList.remove('hidden')
  388. } else {
  389. $(`#${JUMP_TO_BOOKMARK_BUTTON_ID_NAME}`).classList.add('hidden')
  390. }
  391. }
  392. })
  393. observer.observe($('body'), {
  394. childList: true, subTree: true
  395. })
  396. }
  397.  
  398. async function main() {
  399. observeLocationChange()
  400. // register a menu command
  401. registerMenuToggleHideEarlyAccess()
  402. // add a jump to bookmark button
  403. addScrollToBookmarkButton();
  404.  
  405. addLookingForTheBookmarkMessage();
  406.  
  407.  
  408. if (!window.location.href.endsWith('models')) {
  409. return;
  410. }
  411.  
  412. if (! await isSortByNewest()) {
  413. console.info('this script should run only on newest sort mode');
  414. return;
  415. }
  416.  
  417. $(`#${JUMP_TO_BOOKMARK_BUTTON_ID_NAME}`).classList.remove('hidden')
  418. await initialScrollToTheBookmark();
  419.  
  420.  
  421. // hide Early Access models
  422. // FIXME: this works only when the sripts starts on models page
  423. $('.scroll-area').addEventListener('scrollend', hideEarlyAccess)
  424.  
  425. }
  426.  
  427. main()
  428.