@kangsicj/dcinsideenhancer 1.2.2 → 1.3.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.en.md CHANGED
@@ -64,7 +64,8 @@ One userscript for DCInside on mobile and PC: smart blocking, endless lists, ima
64
64
  - Come back from a post and you're **right where you left off**
65
65
  - Cleaner read-post colors and a back-to-top button
66
66
  - Keyboard shortcuts on PC: **J/K** next/previous post, Enter to open, P to preview, S to scrap, / to search
67
- - On PC, hover a title to **preview the whole post** — blocked images and words stay hidden there too
67
+ - On PC, **press and hold** a title to preview the whole post, iPhone-style — blocked images and words stay hidden there too
68
+ - **Pin the order** of recently visited galleries — it stays put wherever you go
68
69
 
69
70
  </td>
70
71
  </tr>
package/README.md CHANGED
@@ -64,7 +64,8 @@
64
64
  - 글을 보고 돌아오면 **보던 위치 그대로**
65
65
  - 읽은 글 색 정리, 맨 위로 버튼
66
66
  - PC 키보드 단축키: **J/K** 다음·이전 글, Enter 열기, P 미리보기, S 스크랩, / 검색
67
- - PC는 제목에 마우스를 올리면 **글 전체 미리보기** — 차단 이미지·단어는 미리보기에서도 가림
67
+ - PC는 아이폰처럼 제목을 **길게 누르면 글 전체 미리보기** — 차단 이미지·단어는 미리보기에서도 가림
68
+ - 최근 방문 갤러리 **순서 고정** — 어디를 가도 정한 순서 그대로
68
69
 
69
70
  </td>
70
71
  </tr>
@@ -2,7 +2,7 @@
2
2
  // @name DCInside Enhancer
3
3
  // @name:ko 디시 인핸서
4
4
  // @namespace https://github.com/kangsicj/DCInsideEnhancer
5
- // @version 1.2.2
5
+ // @version 1.3.1
6
6
  // @description DCInside filter & enhancer — mobile & PC. Block users/words/heads/notices/images & GIFs across posts, infinite list, zoom viewer, theme sync, encrypted multi-device sync.
7
7
  // @description:ko 디시인사이드 차단·편의 기능 (모바일·PC) - 유저·단어(제목/본문)·말머리·공지 차단, 같은/비슷한 이미지·움짤 모든 글 차단, 디시콘 제거, 차단 미디어 데이터 절약, 목록 자동 보충·무한 스크롤·뒤로 가기 위치 복원, 스크랩, 이미지 확대뷰 개선, 야간모드 자동 연동, 암호화 기기 간 동기화
8
8
  // @author kangsicj
@@ -2,7 +2,7 @@
2
2
  // @name DCInside Enhancer
3
3
  // @name:ko 디시 인핸서
4
4
  // @namespace https://github.com/kangsicj/DCInsideEnhancer
5
- // @version 1.2.2
5
+ // @version 1.3.1
6
6
  // @description DCInside filter & enhancer — mobile & PC. Block users/words/heads/notices/images & GIFs across posts, infinite list, zoom viewer, theme sync, encrypted multi-device sync.
7
7
  // @description:ko 디시인사이드 차단·편의 기능 (모바일·PC) - 유저·단어(제목/본문)·말머리·공지 차단, 같은/비슷한 이미지·움짤 모든 글 차단, 디시콘 제거, 차단 미디어 데이터 절약, 목록 자동 보충·무한 스크롤·뒤로 가기 위치 복원, 스크랩, 이미지 확대뷰 개선, 야간모드 자동 연동, 암호화 기기 간 동기화
8
8
  // @author kangsicj
@@ -75,7 +75,7 @@
75
75
  // 0. 설정·상수
76
76
  // =========================================================
77
77
 
78
- const SCRIPT_VERSION = '1.2.2';
78
+ const SCRIPT_VERSION = '1.3.1';
79
79
  const PROJECT_URL = 'https://github.com/kangsicj/DCInsideEnhancer';
80
80
  const DONATE_URL = 'https://fairy.hada.io/@jinny#support';
81
81
 
@@ -106,6 +106,7 @@
106
106
  syncLock: 'dc_sync_lock',
107
107
  syncRt: 'dc_sync_rt',
108
108
  recent: 'dc_recent_galls',
109
+ recentOrder: 'dc_recent_order',
109
110
  recentNative: 'dc_recent_native',
110
111
  searchHistory: 'dc_search_history',
111
112
  blockHits: 'dc_block_hits',
@@ -221,11 +222,14 @@
221
222
  hideAdSlots: true,
222
223
  hideNewsRows: true,
223
224
  hideCommentAds: true,
225
+ hideCommentFab: true,
226
+ mobileRecentStrip: true,
224
227
  hideSilbeAlarm: true,
225
228
  alarmBubble: true,
226
229
  alarmKeepPolling: true,
227
230
  keyboardShortcuts: true,
228
231
  hoverPreview: true,
232
+ previewHover: false,
229
233
  alarmSystemNotify: false,
230
234
  pcFitWidth: true,
231
235
  autoHideScrollbar: true,
@@ -474,7 +478,17 @@
474
478
  html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) > .fr { flex: 0 0 auto; float: none !important; margin-left: 10px; }
475
479
 
476
480
  html.dc-pc-fit .dcwrap .writing_view_box { float: none !important; width: auto !important; }
477
- html.dc-pc-fit .dcwrap .writing_view_box .write_div { width: auto !important; }
481
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div {
482
+ width: auto !important;
483
+ max-width: min(100%, 900px) !important;
484
+ margin-left: auto !important;
485
+ margin-right: auto !important;
486
+ }
487
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div :is(img, video, iframe) { margin-left: auto; margin-right: auto; }
488
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div img { display: block; height: auto; }
489
+
490
+ html.dc-pc-fit .dcwrap .comment_count { display: flow-root; }
491
+ html.dc-pc-fit .dcwrap :is(.cmt_list, .comment_box) { clear: both; }
478
492
 
479
493
  html.dc-pc-fit .dcwrap .bottom_paging_wrap {
480
494
  display: flex !important;
@@ -507,6 +521,7 @@
507
521
  html.dc-ad-slots-off .dcwrap :is(.stickyunit, .rightbanner, .rightbanner1, .banner_box, .ad_bottom_list, #ad_floating, .kakao_ad_area, .ad_top, .view_ad_wrap, .power_link, .con_banner.writing_banbox, #ad_nv_slot, ins.adsbygoogle),
508
522
  html.dc-ad-slots-off :is(.adv-group, li.adv-inner, section.grid.clkhigh, #home-ad, .kakao_ad_area) { display: none !important; }
509
523
  html.dc-news-rows-off :is(table.gall_list tr.ub-content[data-type="icon_fnews"], .gall-detail-lst > li:has(.sp-lst-fnews)) { display: none !important; }
524
+ html.dc-comment-fab-off :is(#cmt_fab, .fab-wrap:has(> #cmt_fab)) { display: none !important; }
510
525
  html.dc-comment-ads-off :is(.comment_box li.ub-content.dory, .comment_box li.cmt_pw_ad, .all-comment-lst > li.dc-comment-boy) { display: none !important; }
511
526
  html.dc-silbe-alarm-off :is(.alarm-popup.silbe-alarm, .rtbAlarmPopup) { display: none !important; }
512
527
  html.dc-alarm-bubble :is(.alarm-popup.alarm-default, .alarmPopup) { display: none !important; }
@@ -1223,6 +1238,9 @@
1223
1238
  #dc-custom-modal .dc-btn.mini.id-type { color: var(--dc-type-id); }
1224
1239
  #dc-custom-modal .dc-btn.mini.nick-type { color: var(--dc-text-dim); }
1225
1240
 
1241
+ #dc-custom-modal .dc-btn:disabled { opacity: .35; cursor: default; }
1242
+ #dc-custom-modal .dc-btn.on-state { background: var(--dc-success); }
1243
+
1226
1244
  #dc-custom-modal .dc-btn.mini.on {
1227
1245
  border-color: var(--dc-danger);
1228
1246
  background: var(--dc-danger);
@@ -2030,8 +2048,79 @@
2030
2048
  font-weight: 700;
2031
2049
  }
2032
2050
 
2051
+ #dc-preview-backdrop {
2052
+ position: fixed;
2053
+ inset: 0;
2054
+ z-index: 99996;
2055
+ background: rgba(0,0,0,.18);
2056
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
2057
+ backdrop-filter: blur(14px) saturate(1.1);
2058
+ opacity: 0;
2059
+ visibility: hidden;
2060
+ transition: opacity .2s ease, visibility 0s linear .2s;
2061
+ }
2062
+
2063
+ html.dc-dark #dc-preview-backdrop { background: rgba(0,0,0,.4); }
2064
+ #dc-preview-backdrop.show { opacity: 1; visibility: visible; transition: opacity .2s ease, visibility 0s; }
2065
+
2066
+ #dc-preview.dc-pv-peek { transform: var(--dc-pv-from, translate(-50%, -50%) scale(.9)); transition: opacity .18s ease, transform .32s cubic-bezier(.2,.9,.3,1.15), visibility 0s linear .32s; }
2067
+ #dc-preview.dc-pv-peek.show { transform: translate(-50%, -50%); transition: opacity .18s ease, transform .32s cubic-bezier(.2,.9,.3,1.15), visibility 0s; }
2068
+ #dc-preview.dc-pv-peek { box-shadow: 0 24px 80px rgba(0,0,0,.35); }
2069
+
2033
2070
  .dc-pv-anchor { text-decoration: underline !important; text-decoration-color: var(--dc-accent-ink) !important; }
2034
2071
 
2072
+ html.dc-recent-strip-on #lately_pop.dc-strip-lately #swiper-lately,
2073
+ html.dc-recent-strip-on #lately_pop.dc-strip-lately #lately_paging1 { display: none !important; }
2074
+ .dc-recent-strip {
2075
+ display: flex;
2076
+ gap: 8px;
2077
+ padding: 12px 12px 14px;
2078
+ overflow-x: auto;
2079
+ overflow-y: hidden;
2080
+ -webkit-overflow-scrolling: touch;
2081
+ scroll-snap-type: x proximity;
2082
+ scrollbar-width: none;
2083
+ overscroll-behavior-x: contain;
2084
+ }
2085
+ .dc-recent-strip::-webkit-scrollbar { display: none; }
2086
+ .dc-recent-strip[hidden] { display: none !important; }
2087
+ .dc-recent-strip .dc-rs-chip {
2088
+ flex: 0 0 auto;
2089
+ display: inline-flex;
2090
+ align-items: center;
2091
+ gap: 4px;
2092
+ max-width: 60vw;
2093
+ height: 36px;
2094
+ padding: 0 4px 0 14px;
2095
+ border: 1px solid var(--dc-border);
2096
+ border-radius: 999px;
2097
+ background: var(--dc-surface-2);
2098
+ color: var(--dc-text);
2099
+ font-size: 14px;
2100
+ font-weight: 600;
2101
+ text-decoration: none;
2102
+ white-space: nowrap;
2103
+ scroll-snap-align: start;
2104
+ }
2105
+ .dc-recent-strip .dc-rs-chip.here { border-color: var(--dc-accent-ink); color: var(--dc-accent-ink); background: var(--dc-accent-soft); }
2106
+ .dc-recent-strip .dc-rs-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
2107
+ .dc-recent-strip .dc-rs-kind { color: var(--dc-text-dim); font-size: 13px; }
2108
+ .dc-recent-strip .dc-rs-del {
2109
+ flex: 0 0 auto;
2110
+ width: 28px;
2111
+ height: 28px;
2112
+ margin: 0;
2113
+ padding: 0;
2114
+ border: none;
2115
+ border-radius: 50%;
2116
+ background: transparent;
2117
+ color: var(--dc-text-dim);
2118
+ font-size: 18px;
2119
+ line-height: 28px;
2120
+ }
2121
+ .dc-recent-strip .dc-rs-pin,
2122
+ .dc-recent-strip .dc-rs-empty { flex: 0 0 auto; align-self: center; padding: 0 6px; color: var(--dc-text-dim); font-size: 12px; white-space: nowrap; }
2123
+
2035
2124
  .dc-key-cursor { outline: 2px solid var(--dc-accent-ink) !important; outline-offset: -2px; background: var(--dc-accent-soft) !important; }
2036
2125
 
2037
2126
  #dc-custom-modal .dc-about {
@@ -3158,6 +3247,7 @@
3158
3247
  root.classList.toggle('dc-ad-slots-off', settings.hideAdSlots !== false);
3159
3248
  root.classList.toggle('dc-news-rows-off', settings.hideNewsRows !== false);
3160
3249
  root.classList.toggle('dc-comment-ads-off', settings.hideCommentAds !== false);
3250
+ root.classList.toggle('dc-comment-fab-off', settings.hideCommentFab !== false);
3161
3251
  root.classList.toggle('dc-silbe-alarm-off', settings.hideSilbeAlarm !== false);
3162
3252
  root.classList.toggle('dc-alarm-bubble', settings.alarmBubble !== false);
3163
3253
  root.classList.toggle('dc-hide-scrollbar', settings.autoHideScrollbar !== false && root.classList.contains('dc-classic-scrollbar'));
@@ -9507,7 +9597,10 @@
9507
9597
  link.addEventListener('click', () => closeBlockModal());
9508
9598
  actionsEl.appendChild(link);
9509
9599
  } else {
9510
- actionsEl.appendChild(makeButton(action.label, `mini ${action.className || ''}`, action.onClick));
9600
+ const btn = makeButton(action.label, `mini ${action.className || ''}`, action.onClick);
9601
+
9602
+ btn.disabled = !!action.disabled;
9603
+ actionsEl.appendChild(btn);
9511
9604
  }
9512
9605
  });
9513
9606
 
@@ -10405,6 +10498,22 @@
10405
10498
  off: '댓글돌이를 그대로 보여 줍니다.',
10406
10499
  refresh: 'root'
10407
10500
  },
10501
+ {
10502
+ group: 'clean',
10503
+ key: 'hideCommentFab',
10504
+ name: "떠 있는 '댓글로 이동' 버튼 숨기기 (모바일)",
10505
+ on: '글 화면 오른쪽 아래에 떠 있는 댓글 버튼을 숨깁니다. 본문 아래 [댓글] 링크와 차단 관리 버튼은 그대로입니다.',
10506
+ off: '댓글로 이동 버튼을 보여 줍니다.',
10507
+ refresh: 'root'
10508
+ },
10509
+ {
10510
+ group: 'clean',
10511
+ key: 'mobileRecentStrip',
10512
+ name: "'최근 방문'을 한 줄로 밀어 보기 (모바일)",
10513
+ on: '위쪽 [최근 방문]을 누르면 넓은 표 대신 갤러리가 순서대로 한 줄에 나오고, 옆으로 밀어 봅니다. 고정한 순서를 그대로 따릅니다.',
10514
+ off: '사이트 기본 표(2줄 × 4칸, 넘겨 보기)로 보여 줍니다.',
10515
+ refresh: 'root'
10516
+ },
10408
10517
  {
10409
10518
  group: 'clean',
10410
10519
  key: 'hideAdSlots',
@@ -10490,9 +10599,18 @@
10490
10599
  {
10491
10600
  group: 'list',
10492
10601
  key: 'hoverPreview',
10493
- name: '제목에 마우스를 올리면 글 미리보기 (PC)',
10494
- on: '목록 제목 위에 잠깐 머무르면 글 전체를 옆 창에 보여 줍니다. 창 안에서 스크롤해 읽고, 스크랩·작성자 차단도 바로 할 수 있습니다. 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10495
- off: '제목을 눌러야 글을 봅니다.',
10602
+ name: '제목을 길게 누르면 글 미리보기 (PC)',
10603
+ on: '아이폰처럼 목록 제목을 길게 누르면 화면이 흐려지며 글 전체가 가운데에 떠오릅니다. 짧게 누르면 평소처럼 글로 갑니다. 창에서 본문을 누르면 글로 이동, 바깥을 누르거나 Esc 로 닫습니다. 스크랩·작성자 차단도 바로 할 수 있고, 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10604
+ off: '미리보기를 쓰지 않습니다.',
10605
+ refresh: 'root'
10606
+ },
10607
+ {
10608
+ group: 'list',
10609
+ key: 'previewHover',
10610
+ parent: 'hoverPreview',
10611
+ name: '마우스를 올려 멈춰도 미리보기 (PC)',
10612
+ on: '제목 위에 마우스를 잠시 멈추면 누르지 않아도 미리보기가 뜹니다. 창 밖으로 마우스가 나가면 닫힙니다.',
10613
+ off: '길게 눌렀을 때만 미리보기가 뜹니다.',
10496
10614
  refresh: 'root'
10497
10615
  },
10498
10616
  {
@@ -10688,6 +10806,10 @@
10688
10806
  repinReadColors();
10689
10807
  }
10690
10808
 
10809
+ if (item.key === 'mobileRecentStrip') {
10810
+ renderRecentStrip();
10811
+ }
10812
+
10691
10813
  if (item.key === 'alarmBubble') {
10692
10814
  paintAlarmBadge();
10693
10815
  }
@@ -11759,7 +11881,7 @@
11759
11881
  { key: 'words', label: '단어', fields: ['keywords', 'titles'] },
11760
11882
  { key: 'heads', label: '말머리', fields: ['heads'] },
11761
11883
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11762
- { key: 'recent', label: '최근 갤러리', fields: ['recent'] },
11884
+ { key: 'recent', label: '최근 갤러리', fields: ['recent', 'recentOrder'] },
11763
11885
  { key: 'search', label: '검색 기록', fields: ['search'] },
11764
11886
  { key: 'settings', label: '설정', fields: ['settings'] }
11765
11887
  ];
@@ -11929,6 +12051,7 @@
11929
12051
  scraps: scraps.map(item => ({ ...item })),
11930
12052
  settings: syncedSettings(),
11931
12053
  recent: collectRecentForSync(),
12054
+ recentOrder: collectRecentOrderForSync(),
11932
12055
  search: collectSearchForSync(),
11933
12056
  hits: collectHitsForSync()
11934
12057
  });
@@ -12038,6 +12161,7 @@
12038
12161
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
12039
12162
  settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
12040
12163
  recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
12164
+ recentOrder: mergeRecentOrder(local.recentOrder, remote && remote.recentOrder),
12041
12165
  search: mergeSearchHistory(local.search, base && base.search, remote && remote.search),
12042
12166
  hits: mergeBlockHits(local.hits, remote && remote.hits)
12043
12167
  };
@@ -12093,6 +12217,7 @@
12093
12217
  const data = collectSyncData();
12094
12218
 
12095
12219
  delete data.recent;
12220
+ delete data.recentOrder;
12096
12221
  delete data.search;
12097
12222
  delete data.hits;
12098
12223
 
@@ -12100,6 +12225,7 @@
12100
12225
  };
12101
12226
 
12102
12227
  const applySyncData = data => {
12228
+ const orderChanged = data && 'recentOrder' in data ? applyRecentOrderFromSync(data.recentOrder) : false;
12103
12229
  const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
12104
12230
  const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
12105
12231
  const hitsChanged = data && 'hits' in data ? applyHitsFromSync(data.hits) : false;
@@ -12157,7 +12283,7 @@
12157
12283
  refreshAfterBlockChange();
12158
12284
  }
12159
12285
 
12160
- return changed || recentChanged || searchChanged || hitsChanged;
12286
+ return changed || recentChanged || orderChanged || searchChanged || hitsChanged;
12161
12287
  };
12162
12288
 
12163
12289
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -13401,6 +13527,38 @@
13401
13527
  };
13402
13528
  };
13403
13529
 
13530
+ const readRecentOrder = () => {
13531
+ const stored = readObject(STORAGE.recentOrder);
13532
+
13533
+ return {
13534
+ fixed: stored.fixed === true,
13535
+ keys: Array.isArray(stored.keys) ? stored.keys.filter(key => typeof key === 'string').slice(0, 200) : [],
13536
+ at: Number(stored.at) || 0
13537
+ };
13538
+ };
13539
+
13540
+ let recentOrder = readRecentOrder();
13541
+
13542
+ const saveRecentOrder = patch => {
13543
+ recentOrder = { ...recentOrder, ...patch, at: Date.now() };
13544
+ writeJson(STORAGE.recentOrder, recentOrder);
13545
+ };
13546
+
13547
+ const orderRecent = list => {
13548
+ const byTime = [...list].sort((a, b) => b.at - a.at);
13549
+
13550
+ if (!recentOrder.fixed) {
13551
+ return byTime;
13552
+ }
13553
+
13554
+ const index = new Map(recentOrder.keys.map((key, i) => [key, i]));
13555
+ const known = byTime.filter(item => index.has(recentKeyOf(item))).sort((a, b) => index.get(recentKeyOf(a)) - index.get(recentKeyOf(b)));
13556
+
13557
+ const fresh = byTime.filter(item => !index.has(recentKeyOf(item))).reverse();
13558
+
13559
+ return [...known, ...fresh];
13560
+ };
13561
+
13404
13562
  const tidyRecent = list => {
13405
13563
  const best = new Map();
13406
13564
 
@@ -13413,7 +13571,62 @@
13413
13571
  }
13414
13572
  });
13415
13573
 
13416
- return [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13574
+ const kept = [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13575
+ const ordered = orderRecent(kept);
13576
+
13577
+ if (recentOrder.fixed) {
13578
+ const keys = ordered.map(recentKeyOf);
13579
+
13580
+ if (JSON.stringify(keys) !== JSON.stringify(recentOrder.keys.filter(key => keys.includes(key)))) {
13581
+ recentOrder = { ...recentOrder, keys };
13582
+ writeJson(STORAGE.recentOrder, recentOrder);
13583
+ }
13584
+ }
13585
+
13586
+ return ordered;
13587
+ };
13588
+
13589
+ const setRecentFixed = fixed => {
13590
+ saveRecentOrder({ fixed, keys: recentGalls.map(recentKeyOf) });
13591
+ recentGalls = tidyRecent(recentGalls);
13592
+ writeJson(STORAGE.recent, recentGalls);
13593
+ exportNativeRecent();
13594
+ reorderNativeRecentDom();
13595
+ };
13596
+
13597
+ const moveRecent = (item, step) => {
13598
+ const list = [...recentGalls];
13599
+ const from = list.indexOf(item);
13600
+ const to = Math.max(0, Math.min(list.length - 1, from + step));
13601
+
13602
+ if (from < 0 || from === to) {
13603
+ return;
13604
+ }
13605
+
13606
+ list.splice(to, 0, ...list.splice(from, 1));
13607
+ saveRecentOrder({ fixed: true, keys: list.map(recentKeyOf) });
13608
+ recentGalls = tidyRecent(list);
13609
+ writeJson(STORAGE.recent, recentGalls);
13610
+ exportNativeRecent();
13611
+ reorderNativeRecentDom();
13612
+ };
13613
+
13614
+ const reorderNativeRecentDom = () => {
13615
+ if (!IS_PC_SITE || !recentOrder.fixed) {
13616
+ return;
13617
+ }
13618
+
13619
+ const index = new Map(recentGalls.map((item, i) => [item.id, i]));
13620
+
13621
+ document.querySelectorAll('#visit_history .vst_listbox, .visit_tablist .under_listbox, #visit_history_lyr .vst_list').forEach(box => {
13622
+ const items = [...box.children].filter(li => li.tagName === 'LI');
13623
+ const idOf = li => li.querySelector('[data-id]')?.getAttribute('data-id') || (/lately_gall_(\S+)/.exec(li.className) || [])[1] || '';
13624
+ const sorted = [...items].sort((a, b) => (index.has(idOf(a)) ? index.get(idOf(a)) : 999) - (index.has(idOf(b)) ? index.get(idOf(b)) : 999));
13625
+
13626
+ if (sorted.some((li, i) => li !== items[i])) {
13627
+ sorted.forEach(li => box.appendChild(li));
13628
+ }
13629
+ });
13417
13630
  };
13418
13631
 
13419
13632
  let recentGalls = tidyRecent(readArray(STORAGE.recent));
@@ -13636,19 +13849,201 @@
13636
13849
  saveRecent();
13637
13850
  };
13638
13851
 
13852
+ const watchNativeRecentDom = () => {
13853
+ if (!IS_PC_SITE) {
13854
+ return;
13855
+ }
13856
+
13857
+ const target = document.getElementById('visit_history');
13858
+
13859
+ if (!target) {
13860
+ return;
13861
+ }
13862
+
13863
+ let timer = null;
13864
+
13865
+ new MutationObserver(() => {
13866
+ if (!recentOrder.fixed) {
13867
+ return;
13868
+ }
13869
+
13870
+ clearTimeout(timer);
13871
+ timer = setTimeout(reorderNativeRecentDom, 60);
13872
+ }).observe(target, { childList: true, subtree: true });
13873
+
13874
+ reorderNativeRecentDom();
13875
+ };
13876
+
13639
13877
  const initRecentEarly = () => {
13640
13878
  importNativeRecent();
13641
13879
  exportNativeRecent();
13642
13880
  };
13643
13881
 
13882
+ const fixNativeLatelyValue = value => {
13883
+ if (!recentOrder.fixed) {
13884
+ return value;
13885
+ }
13886
+
13887
+ try {
13888
+ const parts = unescape(String(value)).split(',').filter(Boolean);
13889
+ const index = new Map(recentGalls.map((item, i) => [item.id, i]));
13890
+ const seen = new Set();
13891
+ const unique = parts.filter(part => {
13892
+ const id = part.split('|')[0];
13893
+
13894
+ if (!id || seen.has(id)) {
13895
+ return false;
13896
+ }
13897
+
13898
+ seen.add(id);
13899
+ return true;
13900
+ });
13901
+ const known = unique.filter(part => index.has(part.split('|')[0])).sort((a, b) => index.get(a.split('|')[0]) - index.get(b.split('|')[0]));
13902
+ const fresh = unique.filter(part => !index.has(part.split('|')[0]));
13903
+
13904
+ return escape([...known, ...fresh].map(part => `${part},`).join(''));
13905
+ } catch {
13906
+ return value;
13907
+ }
13908
+ };
13909
+
13910
+ if (IS_MOBILE_SITE) {
13911
+ try {
13912
+ const proto = window.Storage && window.Storage.prototype;
13913
+ const original = proto && proto.setItem;
13914
+
13915
+ if (original && !original.__dcLately) {
13916
+ const patched = function (key, value) {
13917
+ if (this === window.localStorage && key === 'm_dcinside_lately') {
13918
+ return original.call(this, key, fixNativeLatelyValue(value));
13919
+ }
13920
+
13921
+ return original.call(this, key, value);
13922
+ };
13923
+
13924
+ patched.__dcLately = true;
13925
+ proto.setItem = patched;
13926
+ }
13927
+ } catch {
13928
+ }
13929
+ }
13930
+
13931
+ const renderRecentStrip = () => {
13932
+ const pop = document.getElementById('lately_pop');
13933
+
13934
+ if (!IS_MOBILE_SITE || !pop || settings.mobileRecentStrip === false) {
13935
+ document.documentElement.classList.remove('dc-recent-strip-on');
13936
+ pop?.querySelector('.dc-recent-strip')?.remove();
13937
+ return;
13938
+ }
13939
+
13940
+ const latelyTab = document.getElementById('lately_gall');
13941
+ const showing = !latelyTab || latelyTab.classList.contains('on');
13942
+ let strip = pop.querySelector('.dc-recent-strip');
13943
+
13944
+ if (!strip) {
13945
+ strip = h('div', 'dc-ui dc-recent-strip');
13946
+ strip.setAttribute('role', 'list');
13947
+ const slider = pop.querySelector('#swiper-lately') || pop.querySelector('.vst-slider');
13948
+
13949
+ (slider ? slider.parentNode : pop).insertBefore(strip, slider || null);
13950
+ }
13951
+
13952
+ document.documentElement.classList.add('dc-recent-strip-on');
13953
+ strip.hidden = !showing;
13954
+ pop.classList.toggle('dc-strip-lately', showing);
13955
+
13956
+ if (!showing) {
13957
+ return;
13958
+ }
13959
+
13960
+ const keepScroll = strip.scrollLeft;
13961
+
13962
+ strip.textContent = '';
13963
+
13964
+ if (!recentGalls.length) {
13965
+ strip.appendChild(h('span', 'dc-rs-empty', '최근 방문한 갤러리가 없습니다'));
13966
+ return;
13967
+ }
13968
+
13969
+ const here = (currentGalleryVisit() || {}).id;
13970
+
13971
+ recentGalls.forEach(item => {
13972
+ const chip = h('a', `dc-rs-chip${item.id === here ? ' here' : ''}`);
13973
+
13974
+ chip.href = recentUrl(item);
13975
+ chip.setAttribute('role', 'listitem');
13976
+ chip.appendChild(h('span', 'dc-rs-name', item.n || item.id));
13977
+
13978
+ if (RECENT_KIND_LABEL[item.k]) {
13979
+ chip.appendChild(h('span', 'dc-rs-kind', item.k === 'M' ? 'ⓜ' : item.k === 'N' ? 'ⓝ' : 'ⓟ'));
13980
+ }
13981
+
13982
+ const del = h('button', 'dc-rs-del', '×');
13983
+
13984
+ del.type = 'button';
13985
+ del.setAttribute('aria-label', `${item.n || item.id} 지우기`);
13986
+ del.addEventListener('click', event => {
13987
+ event.preventDefault();
13988
+ event.stopPropagation();
13989
+ recentGalls = recentGalls.filter(other => other !== item);
13990
+ saveRecent();
13991
+ exportNativeRecent();
13992
+ renderRecentStrip();
13993
+ });
13994
+ chip.appendChild(del);
13995
+ strip.appendChild(chip);
13996
+ });
13997
+
13998
+ if (recentOrder.fixed) {
13999
+ strip.appendChild(h('span', 'dc-rs-pin', '📌 순서 고정'));
14000
+ }
14001
+
14002
+ strip.scrollLeft = keepScroll;
14003
+
14004
+ strip.querySelector('.dc-rs-chip.here')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
14005
+ };
14006
+
14007
+ const watchRecentStrip = () => {
14008
+ const pop = document.getElementById('lately_pop');
14009
+
14010
+ if (!IS_MOBILE_SITE || !pop) {
14011
+ return;
14012
+ }
14013
+
14014
+ let timer = null;
14015
+ const refresh = () => {
14016
+ clearTimeout(timer);
14017
+ timer = setTimeout(() => {
14018
+ if (pop.style.display !== 'none') {
14019
+ importNativeRecent();
14020
+ renderRecentStrip();
14021
+ }
14022
+ }, 30);
14023
+ };
14024
+
14025
+ new MutationObserver(refresh).observe(pop, { attributes: true, attributeFilter: ['style'] });
14026
+
14027
+ const tabs = pop.querySelector('.tab-lst');
14028
+
14029
+ if (tabs) {
14030
+ new MutationObserver(refresh).observe(tabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
14031
+ }
14032
+
14033
+ renderRecentStrip();
14034
+ };
14035
+
13644
14036
  const initRecent = () => {
13645
14037
  importNativeRecent();
13646
14038
  recordGalleryVisit();
13647
14039
  exportNativeRecent();
14040
+ watchNativeRecentDom();
14041
+ watchRecentStrip();
13648
14042
 
13649
14043
  const follow = () => {
13650
- if (document.visibilityState === 'visible' && importNativeRecent()) {
14044
+ if (document.visibilityState === 'visible' && (importNativeRecent() || recentOrder.fixed)) {
13651
14045
  exportNativeRecent();
14046
+ reorderNativeRecentDom();
13652
14047
  }
13653
14048
  };
13654
14049
 
@@ -13658,6 +14053,31 @@
13658
14053
 
13659
14054
  const collectRecentForSync = () => recentGalls.map(item => ({ ...item }));
13660
14055
 
14056
+ const collectRecentOrderForSync = () => ({ ...recentOrder, keys: [...recentOrder.keys] });
14057
+
14058
+ const mergeRecentOrder = (local, remote) => {
14059
+ if (!remote || typeof remote !== 'object' || !Array.isArray(remote.keys)) {
14060
+ return local;
14061
+ }
14062
+
14063
+ return (Number(remote.at) || 0) > (Number(local && local.at) || 0) ? remote : local;
14064
+ };
14065
+
14066
+ const applyRecentOrderFromSync = data => {
14067
+ if (!data || typeof data !== 'object' || !Array.isArray(data.keys) || (Number(data.at) || 0) <= recentOrder.at) {
14068
+ return false;
14069
+ }
14070
+
14071
+ recentOrder = { fixed: data.fixed === true, keys: data.keys.filter(key => typeof key === 'string').slice(0, 200), at: Number(data.at) };
14072
+ writeJson(STORAGE.recentOrder, recentOrder);
14073
+ recentGalls = tidyRecent(recentGalls);
14074
+ writeJson(STORAGE.recent, recentGalls);
14075
+ exportNativeRecent();
14076
+ reorderNativeRecentDom();
14077
+
14078
+ return true;
14079
+ };
14080
+
13661
14081
  const mergeRecent = (local = [], base, remote) => {
13662
14082
  if (!Array.isArray(remote)) {
13663
14083
  return tidyRecent(local || []);
@@ -14246,18 +14666,35 @@
14246
14666
  return;
14247
14667
  }
14248
14668
 
14669
+ const orderRow = h('div', 'dc-btn-row');
14670
+
14671
+ orderRow.appendChild(makeButton(recentOrder.fixed ? '📌 순서 고정 중 · 끄기' : '📌 지금 순서로 고정', recentOrder.fixed ? 'wide on-state' : 'wide', () => {
14672
+ setRecentFixed(!recentOrder.fixed);
14673
+ showToast(recentOrder.fixed ? '📌 최근 방문 순서를 고정했습니다 · ↑↓ 로 바꿀 수 있어요' : '최근 방문 순서 고정을 풀었습니다 (방문한 순서로)');
14674
+ rerenderModal();
14675
+ }));
14676
+ area.appendChild(orderRow);
14677
+
14678
+ if (recentOrder.fixed) {
14679
+ area.appendChild(makeNote('고정한 순서는 디시 화면의 최근 방문 목록에도 그대로 보이고, 어느 갤러리를 가도 바뀌지 않습니다. 처음 가는 갤러리는 맨 뒤에 붙습니다.'));
14680
+ }
14681
+
14249
14682
  const filterRow = makeFilterRow(recentGalls.length);
14250
14683
 
14251
14684
  if (filterRow) {
14252
14685
  area.appendChild(filterRow);
14253
14686
  }
14254
14687
 
14255
- recentGalls.forEach(item => {
14688
+ recentGalls.forEach((item, i) => {
14256
14689
  area.appendChild(makeRow({
14257
14690
  title: item.n || item.id,
14258
14691
  chip: RECENT_KIND_LABEL[item.k] || '',
14259
14692
  meta: [item.id, item.at ? timeAgo(item.at) : ''].filter(Boolean).join(' · '),
14260
14693
  actions: [
14694
+ ...(recentOrder.fixed ? [
14695
+ { label: '↑', className: 'dc-icon-btn', onClick: () => { moveRecent(item, -1); rerenderModal(); }, disabled: i === 0 },
14696
+ { label: '↓', className: 'dc-icon-btn', onClick: () => { moveRecent(item, 1); rerenderModal(); }, disabled: i === recentGalls.length - 1 }
14697
+ ] : []),
14261
14698
  { label: '열기', href: recentUrl(item) },
14262
14699
  {
14263
14700
  label: '지우기',
@@ -14525,7 +14962,7 @@
14525
14962
 
14526
14963
  // =========================================================
14527
14964
 
14528
- const CLEANUP_IDLE_DAYS = 60;
14965
+ const CLEANUP_IDLE_DAYS = 7;
14529
14966
 
14530
14967
  const cleanupSuggestions = () => {
14531
14968
  flushBlockHits();
@@ -14762,7 +15199,11 @@
14762
15199
  '</div>' +
14763
15200
  '<div class="dc-pv-body"></div>';
14764
15201
  previewEl.addEventListener('mouseenter', () => clearTimeout(previewCloseTimer));
14765
- previewEl.addEventListener('mouseleave', () => schedulePreviewClose());
15202
+ previewEl.addEventListener('mouseleave', () => {
15203
+ if (previewMode === 'hover') {
15204
+ schedulePreviewClose();
15205
+ }
15206
+ });
14766
15207
  document.body.appendChild(previewEl);
14767
15208
 
14768
15209
  return previewEl;
@@ -14780,6 +15221,7 @@
14780
15221
 
14781
15222
  clearTimeout(previewCloseTimer);
14782
15223
  stopPreviewTheme();
15224
+ previewBackdrop?.classList.remove('show');
14783
15225
 
14784
15226
  if (previewEl) {
14785
15227
  previewEl.classList.remove('show');
@@ -14957,6 +15399,20 @@
14957
15399
  doc.getElementById('dc-pv-css').textContent = previewFrameCss();
14958
15400
  doc.body.appendChild(doc.importNode(body, true));
14959
15401
 
15402
+ doc.querySelectorAll('img').forEach(img => {
15403
+ let tries = 0;
15404
+ const original = img.getAttribute('src') || '';
15405
+
15406
+ img.addEventListener('error', () => {
15407
+ if (tries >= 4 || !/dcinside\.(com|co\.kr)\//.test(original)) {
15408
+ return;
15409
+ }
15410
+
15411
+ tries += 1;
15412
+ setTimeout(() => img.setAttribute('src', `${original}&${tries + 1}`), 250 * tries);
15413
+ });
15414
+ });
15415
+
14960
15416
  doc.querySelectorAll('video[data-src]').forEach(video => {
14961
15417
  const gif = video.getAttribute('data-src');
14962
15418
  let swapped = false;
@@ -15220,18 +15676,135 @@
15220
15676
  }
15221
15677
  };
15222
15678
 
15679
+ const PREVIEW_HOLD_MS = 350;
15680
+ let previewMode = 'peek';
15681
+ let previewBackdrop = null;
15682
+
15683
+ const ensurePreviewBackdrop = () => {
15684
+ if (previewBackdrop && previewBackdrop.isConnected) {
15685
+ return previewBackdrop;
15686
+ }
15687
+
15688
+ previewBackdrop = h('div', 'dc-ui');
15689
+ previewBackdrop.id = 'dc-preview-backdrop';
15690
+ previewBackdrop.addEventListener('mousedown', event => {
15691
+ event.preventDefault();
15692
+ event.stopPropagation();
15693
+ closePreview();
15694
+ });
15695
+ document.body.appendChild(previewBackdrop);
15696
+
15697
+ return previewBackdrop;
15698
+ };
15699
+
15700
+ const openPreview = (anchor, mode) => {
15701
+ previewMode = mode;
15702
+
15703
+ const el = ensurePreviewEl();
15704
+
15705
+ el.classList.toggle('dc-pv-peek', mode === 'peek');
15706
+
15707
+ try {
15708
+ const r = anchor.getBoundingClientRect();
15709
+ const ox = Math.round(r.left + Math.min(r.width, 240) / 2 - window.innerWidth / 2);
15710
+ const oy = Math.round(r.top + r.height / 2 - window.innerHeight / 2);
15711
+
15712
+ el.style.setProperty('--dc-pv-from', `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px)) scale(.35)`);
15713
+ } catch {
15714
+ el.style.removeProperty('--dc-pv-from');
15715
+ }
15716
+
15717
+ if (mode === 'peek') {
15718
+ ensurePreviewBackdrop().classList.add('show');
15719
+ } else {
15720
+ previewBackdrop?.classList.remove('show');
15721
+ }
15722
+
15723
+ showPreviewFor(anchor);
15724
+ };
15725
+
15223
15726
  const initHoverPreview = () => {
15224
15727
  if (!IS_PC_SITE) {
15225
15728
  return;
15226
15729
  }
15227
15730
 
15731
+ const hoverOn = () => previewEnabled() && settings.previewHover === true;
15732
+
15228
15733
  let hoverAnchor = null;
15229
15734
  let pressedAnchor = null;
15735
+ let holdTimer = null;
15736
+ let holdStart = null;
15737
+ let swallowClick = null;
15738
+
15739
+ document.addEventListener('mousedown', event => {
15740
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15741
+
15742
+ if (!anchor || anchor.closest('.dc-ui')) {
15743
+ if (previewOpen() && !(event.target instanceof Node && previewEl.contains(event.target))) {
15744
+ closePreview();
15745
+ }
15746
+
15747
+ return;
15748
+ }
15749
+
15750
+ pressedAnchor = anchor;
15751
+ clearTimeout(previewHoverTimer);
15752
+
15753
+ if (!previewEnabled() || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
15754
+ return;
15755
+ }
15756
+
15757
+ holdStart = { x: event.clientX, y: event.clientY };
15758
+ clearTimeout(holdTimer);
15759
+ holdTimer = setTimeout(() => {
15760
+ holdTimer = null;
15761
+ swallowClick = anchor;
15762
+
15763
+ window.getSelection?.()?.removeAllRanges?.();
15764
+ openPreview(anchor, 'peek');
15765
+ }, PREVIEW_HOLD_MS);
15766
+ }, true);
15767
+
15768
+ document.addEventListener('mousemove', event => {
15769
+ if (holdTimer && holdStart && Math.hypot(event.clientX - holdStart.x, event.clientY - holdStart.y) > 8) {
15770
+ clearTimeout(holdTimer);
15771
+ holdTimer = null;
15772
+ }
15773
+ }, { passive: true, capture: true });
15774
+
15775
+ const cancelHold = () => {
15776
+ clearTimeout(holdTimer);
15777
+ holdTimer = null;
15778
+ };
15779
+
15780
+ document.addEventListener('mouseup', cancelHold, true);
15781
+ document.addEventListener('dragstart', event => {
15782
+ if (swallowClick || holdTimer) {
15783
+ event.preventDefault();
15784
+ }
15785
+
15786
+ cancelHold();
15787
+ }, true);
15788
+
15789
+ document.addEventListener('click', event => {
15790
+ if (!swallowClick) {
15791
+ return;
15792
+ }
15793
+
15794
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15795
+
15796
+ if (anchor === swallowClick) {
15797
+ event.preventDefault();
15798
+ event.stopImmediatePropagation();
15799
+ }
15800
+
15801
+ swallowClick = null;
15802
+ }, true);
15230
15803
 
15231
15804
  const armHover = anchor => {
15232
15805
  clearTimeout(previewHoverTimer);
15233
15806
 
15234
- if (!previewEnabled() || anchor === pressedAnchor) {
15807
+ if (!hoverOn() || anchor === pressedAnchor || (previewOpen() && previewMode === 'peek')) {
15235
15808
  return;
15236
15809
  }
15237
15810
 
@@ -15243,18 +15816,14 @@
15243
15816
  }
15244
15817
 
15245
15818
  clearTimeout(previewCloseTimer);
15246
- showPreviewFor(anchor);
15819
+ openPreview(anchor, 'hover');
15247
15820
  }, switching ? PREVIEW_SWITCH_DELAY : PREVIEW_DELAY);
15248
15821
  };
15249
15822
 
15250
15823
  document.addEventListener('mouseover', event => {
15251
15824
  const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15252
15825
 
15253
- if (!anchor || anchor.closest('.dc-ui')) {
15254
- return;
15255
- }
15256
-
15257
- if (anchor === hoverAnchor) {
15826
+ if (!anchor || anchor.closest('.dc-ui') || anchor === hoverAnchor) {
15258
15827
  return;
15259
15828
  }
15260
15829
 
@@ -15265,7 +15834,7 @@
15265
15834
  return;
15266
15835
  }
15267
15836
 
15268
- if (previewOpen()) {
15837
+ if (previewOpen() && previewMode === 'hover') {
15269
15838
  clearTimeout(previewCloseTimer);
15270
15839
  }
15271
15840
 
@@ -15282,15 +15851,6 @@
15282
15851
  }
15283
15852
  }, { passive: true });
15284
15853
 
15285
- document.addEventListener('mousedown', event => {
15286
- const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15287
-
15288
- if (anchor) {
15289
- pressedAnchor = anchor;
15290
- clearTimeout(previewHoverTimer);
15291
- }
15292
- }, true);
15293
-
15294
15854
  document.addEventListener('mouseout', event => {
15295
15855
  const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15296
15856
 
@@ -15308,19 +15868,13 @@
15308
15868
 
15309
15869
  clearTimeout(previewHoverTimer);
15310
15870
 
15311
- if (previewOpen() && !(event.relatedTarget instanceof Node && previewEl.contains(event.relatedTarget))) {
15871
+ if (previewOpen() && previewMode === 'hover' && !(event.relatedTarget instanceof Node && previewEl.contains(event.relatedTarget))) {
15312
15872
  schedulePreviewClose();
15313
15873
  }
15314
15874
  });
15315
15875
 
15316
- document.addEventListener('mousedown', event => {
15317
- if (previewOpen() && !(event.target instanceof Node && previewEl.contains(event.target))) {
15318
- closePreview();
15319
- }
15320
- }, true);
15321
-
15322
15876
  window.addEventListener('scroll', () => {
15323
- if (previewOpen() && !previewEl.matches(':hover')) {
15877
+ if (previewOpen() && previewMode === 'hover' && !previewEl.matches(':hover')) {
15324
15878
  closePreview();
15325
15879
  }
15326
15880
  }, { passive: true });
@@ -15356,7 +15910,7 @@
15356
15910
  return false;
15357
15911
  }
15358
15912
 
15359
- showPreviewFor(anchor);
15913
+ openPreview(anchor, 'peek');
15360
15914
  return true;
15361
15915
  };
15362
15916
  // =========================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangsicj/dcinsideenhancer",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "DCInside filter & enhancer userscript for mobile and PC (디시인사이드 차단·편의 유저스크립트)",
5
5
  "license": "MIT",
6
6
  "author": "kangsicj",