@kangsicj/dcinsideenhancer 1.2.1 → 1.3.0

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.1
5
+ // @version 1.3.0
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.1
5
+ // @version 1.3.0
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.1';
78
+ const SCRIPT_VERSION = '1.3.0';
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,13 @@
221
222
  hideAdSlots: true,
222
223
  hideNewsRows: true,
223
224
  hideCommentAds: true,
225
+ hideCommentFab: true,
224
226
  hideSilbeAlarm: true,
225
227
  alarmBubble: true,
226
228
  alarmKeepPolling: true,
227
229
  keyboardShortcuts: true,
228
230
  hoverPreview: true,
231
+ previewHover: false,
229
232
  alarmSystemNotify: false,
230
233
  pcFitWidth: true,
231
234
  autoHideScrollbar: true,
@@ -463,14 +466,41 @@
463
466
  html.dc-pc-fit .dcwrap .stickyunit { display: none !important; }
464
467
  html.dc-pc-fit .dcwrap .darkmodebox .pop_tipbox { left: auto !important; right: 0 !important; }
465
468
 
466
- html.dc-pc-fit .dcwrap .cmt_info { display: flex !important; align-items: flex-start; }
467
- html.dc-pc-fit .dcwrap .cmt_info::before,
468
- html.dc-pc-fit .dcwrap .cmt_info::after { display: none !important; }
469
- html.dc-pc-fit .dcwrap .cmt_info > .addbox { display: flex; flex: 1 1 auto; min-width: 0; width: auto !important; }
470
- html.dc-pc-fit .dcwrap .cmt_info > .addbox > .cmt_nickbox { flex: 0 0 auto; float: none !important; }
471
- html.dc-pc-fit .dcwrap .cmt_info > .addbox > .cmt_txtbox { flex: 1 1 auto; min-width: 0; float: none !important; width: auto !important; }
472
- html.dc-pc-fit .dcwrap .cmt_info .cmt_txtbox .usertxt { float: none !important; width: auto !important; max-width: 100% !important; }
473
- html.dc-pc-fit .dcwrap .cmt_info > .fr { flex: 0 0 auto; float: none !important; margin-left: 10px; }
469
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) { display: flex !important; align-items: flex-start; }
470
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info)::before,
471
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info)::after { display: none !important; }
472
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) > .addbox { display: flex; flex: 1 1 auto; min-width: 0; width: auto !important; }
473
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) > .addbox > .cmt_nickbox { flex: 0 0 auto; float: none !important; }
474
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) > .addbox > .cmt_txtbox { flex: 1 1 auto; min-width: 0; float: none !important; width: auto !important; }
475
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) .cmt_txtbox .usertxt { float: none !important; width: auto !important; max-width: 100% !important; }
476
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) .cmt_txtbox .comment_dccon { float: none !important; width: auto !important; max-width: 100% !important; }
477
+ html.dc-pc-fit .dcwrap :is(.cmt_info, .reply_info) > .fr { flex: 0 0 auto; float: none !important; margin-left: 10px; }
478
+
479
+ html.dc-pc-fit .dcwrap .writing_view_box { float: none !important; width: auto !important; }
480
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div {
481
+ width: auto !important;
482
+ max-width: min(100%, 900px) !important;
483
+ margin-left: auto !important;
484
+ margin-right: auto !important;
485
+ }
486
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div :is(img, video, iframe) { margin-left: auto; margin-right: auto; }
487
+ html.dc-pc-fit .dcwrap .writing_view_box .write_div img { display: block; height: auto; }
488
+
489
+ html.dc-pc-fit .dcwrap .comment_count { display: flow-root; }
490
+ html.dc-pc-fit .dcwrap :is(.cmt_list, .comment_box) { clear: both; }
491
+
492
+ html.dc-pc-fit .dcwrap .bottom_paging_wrap {
493
+ display: flex !important;
494
+ justify-content: center;
495
+ align-items: center;
496
+ gap: 10px;
497
+ width: 100% !important;
498
+ padding-left: 0 !important;
499
+ padding-right: 0 !important;
500
+ box-sizing: border-box;
501
+ position: relative;
502
+ }
503
+ html.dc-pc-fit .dcwrap .bottom_paging_wrap > .bottom_paging_box { margin: 0 !important; }
474
504
 
475
505
  html.dc-pc-fit .dcwrap .cmt_write_box > .fl { width: 142px; }
476
506
  html.dc-pc-fit .dcwrap .cmt_write_box > .cmt_txt_cont {
@@ -490,6 +520,7 @@
490
520
  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),
491
521
  html.dc-ad-slots-off :is(.adv-group, li.adv-inner, section.grid.clkhigh, #home-ad, .kakao_ad_area) { display: none !important; }
492
522
  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; }
523
+ html.dc-comment-fab-off :is(#cmt_fab, .fab-wrap:has(> #cmt_fab)) { display: none !important; }
493
524
  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; }
494
525
  html.dc-silbe-alarm-off :is(.alarm-popup.silbe-alarm, .rtbAlarmPopup) { display: none !important; }
495
526
  html.dc-alarm-bubble :is(.alarm-popup.alarm-default, .alarmPopup) { display: none !important; }
@@ -1206,6 +1237,9 @@
1206
1237
  #dc-custom-modal .dc-btn.mini.id-type { color: var(--dc-type-id); }
1207
1238
  #dc-custom-modal .dc-btn.mini.nick-type { color: var(--dc-text-dim); }
1208
1239
 
1240
+ #dc-custom-modal .dc-btn:disabled { opacity: .35; cursor: default; }
1241
+ #dc-custom-modal .dc-btn.on-state { background: var(--dc-success); }
1242
+
1209
1243
  #dc-custom-modal .dc-btn.mini.on {
1210
1244
  border-color: var(--dc-danger);
1211
1245
  background: var(--dc-danger);
@@ -2013,6 +2047,25 @@
2013
2047
  font-weight: 700;
2014
2048
  }
2015
2049
 
2050
+ #dc-preview-backdrop {
2051
+ position: fixed;
2052
+ inset: 0;
2053
+ z-index: 99996;
2054
+ background: rgba(0,0,0,.18);
2055
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
2056
+ backdrop-filter: blur(14px) saturate(1.1);
2057
+ opacity: 0;
2058
+ visibility: hidden;
2059
+ transition: opacity .2s ease, visibility 0s linear .2s;
2060
+ }
2061
+
2062
+ html.dc-dark #dc-preview-backdrop { background: rgba(0,0,0,.4); }
2063
+ #dc-preview-backdrop.show { opacity: 1; visibility: visible; transition: opacity .2s ease, visibility 0s; }
2064
+
2065
+ #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; }
2066
+ #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; }
2067
+ #dc-preview.dc-pv-peek { box-shadow: 0 24px 80px rgba(0,0,0,.35); }
2068
+
2016
2069
  .dc-pv-anchor { text-decoration: underline !important; text-decoration-color: var(--dc-accent-ink) !important; }
2017
2070
 
2018
2071
  .dc-key-cursor { outline: 2px solid var(--dc-accent-ink) !important; outline-offset: -2px; background: var(--dc-accent-soft) !important; }
@@ -3141,6 +3194,7 @@
3141
3194
  root.classList.toggle('dc-ad-slots-off', settings.hideAdSlots !== false);
3142
3195
  root.classList.toggle('dc-news-rows-off', settings.hideNewsRows !== false);
3143
3196
  root.classList.toggle('dc-comment-ads-off', settings.hideCommentAds !== false);
3197
+ root.classList.toggle('dc-comment-fab-off', settings.hideCommentFab !== false);
3144
3198
  root.classList.toggle('dc-silbe-alarm-off', settings.hideSilbeAlarm !== false);
3145
3199
  root.classList.toggle('dc-alarm-bubble', settings.alarmBubble !== false);
3146
3200
  root.classList.toggle('dc-hide-scrollbar', settings.autoHideScrollbar !== false && root.classList.contains('dc-classic-scrollbar'));
@@ -9490,7 +9544,10 @@
9490
9544
  link.addEventListener('click', () => closeBlockModal());
9491
9545
  actionsEl.appendChild(link);
9492
9546
  } else {
9493
- actionsEl.appendChild(makeButton(action.label, `mini ${action.className || ''}`, action.onClick));
9547
+ const btn = makeButton(action.label, `mini ${action.className || ''}`, action.onClick);
9548
+
9549
+ btn.disabled = !!action.disabled;
9550
+ actionsEl.appendChild(btn);
9494
9551
  }
9495
9552
  });
9496
9553
 
@@ -10388,6 +10445,14 @@
10388
10445
  off: '댓글돌이를 그대로 보여 줍니다.',
10389
10446
  refresh: 'root'
10390
10447
  },
10448
+ {
10449
+ group: 'clean',
10450
+ key: 'hideCommentFab',
10451
+ name: "떠 있는 '댓글로 이동' 버튼 숨기기 (모바일)",
10452
+ on: '글 화면 오른쪽 아래에 떠 있는 댓글 버튼을 숨깁니다. 본문 아래 [댓글] 링크와 차단 관리 버튼은 그대로입니다.',
10453
+ off: '댓글로 이동 버튼을 보여 줍니다.',
10454
+ refresh: 'root'
10455
+ },
10391
10456
  {
10392
10457
  group: 'clean',
10393
10458
  key: 'hideAdSlots',
@@ -10473,9 +10538,18 @@
10473
10538
  {
10474
10539
  group: 'list',
10475
10540
  key: 'hoverPreview',
10476
- name: '제목에 마우스를 올리면 글 미리보기 (PC)',
10477
- on: '목록 제목 위에 잠깐 머무르면 글 전체를 옆 창에 보여 줍니다. 창 안에서 스크롤해 읽고, 스크랩·작성자 차단도 바로 할 수 있습니다. 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10478
- off: '제목을 눌러야 글을 봅니다.',
10541
+ name: '제목을 길게 누르면 글 미리보기 (PC)',
10542
+ on: '아이폰처럼 목록 제목을 길게 누르면 화면이 흐려지며 글 전체가 가운데에 떠오릅니다. 짧게 누르면 평소처럼 글로 갑니다. 창에서 본문을 누르면 글로 이동, 바깥을 누르거나 Esc 로 닫습니다. 스크랩·작성자 차단도 바로 할 수 있고, 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10543
+ off: '미리보기를 쓰지 않습니다.',
10544
+ refresh: 'root'
10545
+ },
10546
+ {
10547
+ group: 'list',
10548
+ key: 'previewHover',
10549
+ parent: 'hoverPreview',
10550
+ name: '마우스를 올려 멈춰도 미리보기 (PC)',
10551
+ on: '제목 위에 마우스를 잠시 멈추면 누르지 않아도 미리보기가 뜹니다. 창 밖으로 마우스가 나가면 닫힙니다.',
10552
+ off: '길게 눌렀을 때만 미리보기가 뜹니다.',
10479
10553
  refresh: 'root'
10480
10554
  },
10481
10555
  {
@@ -11742,7 +11816,7 @@
11742
11816
  { key: 'words', label: '단어', fields: ['keywords', 'titles'] },
11743
11817
  { key: 'heads', label: '말머리', fields: ['heads'] },
11744
11818
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11745
- { key: 'recent', label: '최근 갤러리', fields: ['recent'] },
11819
+ { key: 'recent', label: '최근 갤러리', fields: ['recent', 'recentOrder'] },
11746
11820
  { key: 'search', label: '검색 기록', fields: ['search'] },
11747
11821
  { key: 'settings', label: '설정', fields: ['settings'] }
11748
11822
  ];
@@ -11912,6 +11986,7 @@
11912
11986
  scraps: scraps.map(item => ({ ...item })),
11913
11987
  settings: syncedSettings(),
11914
11988
  recent: collectRecentForSync(),
11989
+ recentOrder: collectRecentOrderForSync(),
11915
11990
  search: collectSearchForSync(),
11916
11991
  hits: collectHitsForSync()
11917
11992
  });
@@ -12021,6 +12096,7 @@
12021
12096
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
12022
12097
  settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
12023
12098
  recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
12099
+ recentOrder: mergeRecentOrder(local.recentOrder, remote && remote.recentOrder),
12024
12100
  search: mergeSearchHistory(local.search, base && base.search, remote && remote.search),
12025
12101
  hits: mergeBlockHits(local.hits, remote && remote.hits)
12026
12102
  };
@@ -12076,6 +12152,7 @@
12076
12152
  const data = collectSyncData();
12077
12153
 
12078
12154
  delete data.recent;
12155
+ delete data.recentOrder;
12079
12156
  delete data.search;
12080
12157
  delete data.hits;
12081
12158
 
@@ -12083,6 +12160,7 @@
12083
12160
  };
12084
12161
 
12085
12162
  const applySyncData = data => {
12163
+ const orderChanged = data && 'recentOrder' in data ? applyRecentOrderFromSync(data.recentOrder) : false;
12086
12164
  const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
12087
12165
  const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
12088
12166
  const hitsChanged = data && 'hits' in data ? applyHitsFromSync(data.hits) : false;
@@ -12140,7 +12218,7 @@
12140
12218
  refreshAfterBlockChange();
12141
12219
  }
12142
12220
 
12143
- return changed || recentChanged || searchChanged || hitsChanged;
12221
+ return changed || recentChanged || orderChanged || searchChanged || hitsChanged;
12144
12222
  };
12145
12223
 
12146
12224
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -13384,6 +13462,38 @@
13384
13462
  };
13385
13463
  };
13386
13464
 
13465
+ const readRecentOrder = () => {
13466
+ const stored = readObject(STORAGE.recentOrder);
13467
+
13468
+ return {
13469
+ fixed: stored.fixed === true,
13470
+ keys: Array.isArray(stored.keys) ? stored.keys.filter(key => typeof key === 'string').slice(0, 200) : [],
13471
+ at: Number(stored.at) || 0
13472
+ };
13473
+ };
13474
+
13475
+ let recentOrder = readRecentOrder();
13476
+
13477
+ const saveRecentOrder = patch => {
13478
+ recentOrder = { ...recentOrder, ...patch, at: Date.now() };
13479
+ writeJson(STORAGE.recentOrder, recentOrder);
13480
+ };
13481
+
13482
+ const orderRecent = list => {
13483
+ const byTime = [...list].sort((a, b) => b.at - a.at);
13484
+
13485
+ if (!recentOrder.fixed) {
13486
+ return byTime;
13487
+ }
13488
+
13489
+ const index = new Map(recentOrder.keys.map((key, i) => [key, i]));
13490
+ const known = byTime.filter(item => index.has(recentKeyOf(item))).sort((a, b) => index.get(recentKeyOf(a)) - index.get(recentKeyOf(b)));
13491
+
13492
+ const fresh = byTime.filter(item => !index.has(recentKeyOf(item))).reverse();
13493
+
13494
+ return [...known, ...fresh];
13495
+ };
13496
+
13387
13497
  const tidyRecent = list => {
13388
13498
  const best = new Map();
13389
13499
 
@@ -13396,7 +13506,62 @@
13396
13506
  }
13397
13507
  });
13398
13508
 
13399
- return [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13509
+ const kept = [...best.values()].sort((a, b) => b.at - a.at).slice(0, RECENT_LIMIT);
13510
+ const ordered = orderRecent(kept);
13511
+
13512
+ if (recentOrder.fixed) {
13513
+ const keys = ordered.map(recentKeyOf);
13514
+
13515
+ if (JSON.stringify(keys) !== JSON.stringify(recentOrder.keys.filter(key => keys.includes(key)))) {
13516
+ recentOrder = { ...recentOrder, keys };
13517
+ writeJson(STORAGE.recentOrder, recentOrder);
13518
+ }
13519
+ }
13520
+
13521
+ return ordered;
13522
+ };
13523
+
13524
+ const setRecentFixed = fixed => {
13525
+ saveRecentOrder({ fixed, keys: recentGalls.map(recentKeyOf) });
13526
+ recentGalls = tidyRecent(recentGalls);
13527
+ writeJson(STORAGE.recent, recentGalls);
13528
+ exportNativeRecent();
13529
+ reorderNativeRecentDom();
13530
+ };
13531
+
13532
+ const moveRecent = (item, step) => {
13533
+ const list = [...recentGalls];
13534
+ const from = list.indexOf(item);
13535
+ const to = Math.max(0, Math.min(list.length - 1, from + step));
13536
+
13537
+ if (from < 0 || from === to) {
13538
+ return;
13539
+ }
13540
+
13541
+ list.splice(to, 0, ...list.splice(from, 1));
13542
+ saveRecentOrder({ fixed: true, keys: list.map(recentKeyOf) });
13543
+ recentGalls = tidyRecent(list);
13544
+ writeJson(STORAGE.recent, recentGalls);
13545
+ exportNativeRecent();
13546
+ reorderNativeRecentDom();
13547
+ };
13548
+
13549
+ const reorderNativeRecentDom = () => {
13550
+ if (!IS_PC_SITE || !recentOrder.fixed) {
13551
+ return;
13552
+ }
13553
+
13554
+ const index = new Map(recentGalls.map((item, i) => [item.id, i]));
13555
+
13556
+ document.querySelectorAll('#visit_history .vst_listbox, .visit_tablist .under_listbox, #visit_history_lyr .vst_list').forEach(box => {
13557
+ const items = [...box.children].filter(li => li.tagName === 'LI');
13558
+ const idOf = li => li.querySelector('[data-id]')?.getAttribute('data-id') || (/lately_gall_(\S+)/.exec(li.className) || [])[1] || '';
13559
+ const sorted = [...items].sort((a, b) => (index.has(idOf(a)) ? index.get(idOf(a)) : 999) - (index.has(idOf(b)) ? index.get(idOf(b)) : 999));
13560
+
13561
+ if (sorted.some((li, i) => li !== items[i])) {
13562
+ sorted.forEach(li => box.appendChild(li));
13563
+ }
13564
+ });
13400
13565
  };
13401
13566
 
13402
13567
  let recentGalls = tidyRecent(readArray(STORAGE.recent));
@@ -13619,6 +13784,31 @@
13619
13784
  saveRecent();
13620
13785
  };
13621
13786
 
13787
+ const watchNativeRecentDom = () => {
13788
+ if (!IS_PC_SITE) {
13789
+ return;
13790
+ }
13791
+
13792
+ const target = document.getElementById('visit_history');
13793
+
13794
+ if (!target) {
13795
+ return;
13796
+ }
13797
+
13798
+ let timer = null;
13799
+
13800
+ new MutationObserver(() => {
13801
+ if (!recentOrder.fixed) {
13802
+ return;
13803
+ }
13804
+
13805
+ clearTimeout(timer);
13806
+ timer = setTimeout(reorderNativeRecentDom, 60);
13807
+ }).observe(target, { childList: true, subtree: true });
13808
+
13809
+ reorderNativeRecentDom();
13810
+ };
13811
+
13622
13812
  const initRecentEarly = () => {
13623
13813
  importNativeRecent();
13624
13814
  exportNativeRecent();
@@ -13628,10 +13818,12 @@
13628
13818
  importNativeRecent();
13629
13819
  recordGalleryVisit();
13630
13820
  exportNativeRecent();
13821
+ watchNativeRecentDom();
13631
13822
 
13632
13823
  const follow = () => {
13633
- if (document.visibilityState === 'visible' && importNativeRecent()) {
13824
+ if (document.visibilityState === 'visible' && (importNativeRecent() || recentOrder.fixed)) {
13634
13825
  exportNativeRecent();
13826
+ reorderNativeRecentDom();
13635
13827
  }
13636
13828
  };
13637
13829
 
@@ -13641,6 +13833,31 @@
13641
13833
 
13642
13834
  const collectRecentForSync = () => recentGalls.map(item => ({ ...item }));
13643
13835
 
13836
+ const collectRecentOrderForSync = () => ({ ...recentOrder, keys: [...recentOrder.keys] });
13837
+
13838
+ const mergeRecentOrder = (local, remote) => {
13839
+ if (!remote || typeof remote !== 'object' || !Array.isArray(remote.keys)) {
13840
+ return local;
13841
+ }
13842
+
13843
+ return (Number(remote.at) || 0) > (Number(local && local.at) || 0) ? remote : local;
13844
+ };
13845
+
13846
+ const applyRecentOrderFromSync = data => {
13847
+ if (!data || typeof data !== 'object' || !Array.isArray(data.keys) || (Number(data.at) || 0) <= recentOrder.at) {
13848
+ return false;
13849
+ }
13850
+
13851
+ recentOrder = { fixed: data.fixed === true, keys: data.keys.filter(key => typeof key === 'string').slice(0, 200), at: Number(data.at) };
13852
+ writeJson(STORAGE.recentOrder, recentOrder);
13853
+ recentGalls = tidyRecent(recentGalls);
13854
+ writeJson(STORAGE.recent, recentGalls);
13855
+ exportNativeRecent();
13856
+ reorderNativeRecentDom();
13857
+
13858
+ return true;
13859
+ };
13860
+
13644
13861
  const mergeRecent = (local = [], base, remote) => {
13645
13862
  if (!Array.isArray(remote)) {
13646
13863
  return tidyRecent(local || []);
@@ -14229,18 +14446,35 @@
14229
14446
  return;
14230
14447
  }
14231
14448
 
14449
+ const orderRow = h('div', 'dc-btn-row');
14450
+
14451
+ orderRow.appendChild(makeButton(recentOrder.fixed ? '📌 순서 고정 중 · 끄기' : '📌 지금 순서로 고정', recentOrder.fixed ? 'wide on-state' : 'wide', () => {
14452
+ setRecentFixed(!recentOrder.fixed);
14453
+ showToast(recentOrder.fixed ? '📌 최근 방문 순서를 고정했습니다 · ↑↓ 로 바꿀 수 있어요' : '최근 방문 순서 고정을 풀었습니다 (방문한 순서로)');
14454
+ rerenderModal();
14455
+ }));
14456
+ area.appendChild(orderRow);
14457
+
14458
+ if (recentOrder.fixed) {
14459
+ area.appendChild(makeNote('고정한 순서는 디시 화면의 최근 방문 목록에도 그대로 보이고, 어느 갤러리를 가도 바뀌지 않습니다. 처음 가는 갤러리는 맨 뒤에 붙습니다.'));
14460
+ }
14461
+
14232
14462
  const filterRow = makeFilterRow(recentGalls.length);
14233
14463
 
14234
14464
  if (filterRow) {
14235
14465
  area.appendChild(filterRow);
14236
14466
  }
14237
14467
 
14238
- recentGalls.forEach(item => {
14468
+ recentGalls.forEach((item, i) => {
14239
14469
  area.appendChild(makeRow({
14240
14470
  title: item.n || item.id,
14241
14471
  chip: RECENT_KIND_LABEL[item.k] || '',
14242
14472
  meta: [item.id, item.at ? timeAgo(item.at) : ''].filter(Boolean).join(' · '),
14243
14473
  actions: [
14474
+ ...(recentOrder.fixed ? [
14475
+ { label: '↑', className: 'dc-icon-btn', onClick: () => { moveRecent(item, -1); rerenderModal(); }, disabled: i === 0 },
14476
+ { label: '↓', className: 'dc-icon-btn', onClick: () => { moveRecent(item, 1); rerenderModal(); }, disabled: i === recentGalls.length - 1 }
14477
+ ] : []),
14244
14478
  { label: '열기', href: recentUrl(item) },
14245
14479
  {
14246
14480
  label: '지우기',
@@ -14508,7 +14742,7 @@
14508
14742
 
14509
14743
  // =========================================================
14510
14744
 
14511
- const CLEANUP_IDLE_DAYS = 60;
14745
+ const CLEANUP_IDLE_DAYS = 7;
14512
14746
 
14513
14747
  const cleanupSuggestions = () => {
14514
14748
  flushBlockHits();
@@ -14707,7 +14941,8 @@
14707
14941
 
14708
14942
  // =========================================================
14709
14943
 
14710
- const PREVIEW_DELAY = 450;
14944
+ const PREVIEW_DELAY = 650;
14945
+ const PREVIEW_SWITCH_DELAY = 320;
14711
14946
  const PREVIEW_CLOSE_DELAY = 380;
14712
14947
  const PREVIEW_CACHE_MS = 5 * 60 * 1000;
14713
14948
  const PREVIEW_CACHE_MAX = 30;
@@ -14744,7 +14979,11 @@
14744
14979
  '</div>' +
14745
14980
  '<div class="dc-pv-body"></div>';
14746
14981
  previewEl.addEventListener('mouseenter', () => clearTimeout(previewCloseTimer));
14747
- previewEl.addEventListener('mouseleave', () => schedulePreviewClose());
14982
+ previewEl.addEventListener('mouseleave', () => {
14983
+ if (previewMode === 'hover') {
14984
+ schedulePreviewClose();
14985
+ }
14986
+ });
14748
14987
  document.body.appendChild(previewEl);
14749
14988
 
14750
14989
  return previewEl;
@@ -14755,10 +14994,14 @@
14755
14994
  previewThemeObserver = null;
14756
14995
  };
14757
14996
 
14758
- const closePreview = () => {
14759
- clearTimeout(previewHoverTimer);
14997
+ const closePreview = ({ keepHover = false } = {}) => {
14998
+ if (!keepHover) {
14999
+ clearTimeout(previewHoverTimer);
15000
+ }
15001
+
14760
15002
  clearTimeout(previewCloseTimer);
14761
15003
  stopPreviewTheme();
15004
+ previewBackdrop?.classList.remove('show');
14762
15005
 
14763
15006
  if (previewEl) {
14764
15007
  previewEl.classList.remove('show');
@@ -14774,7 +15017,8 @@
14774
15017
 
14775
15018
  const schedulePreviewClose = () => {
14776
15019
  clearTimeout(previewCloseTimer);
14777
- previewCloseTimer = setTimeout(closePreview, PREVIEW_CLOSE_DELAY);
15020
+
15021
+ previewCloseTimer = setTimeout(() => closePreview({ keepHover: true }), PREVIEW_CLOSE_DELAY);
14778
15022
  };
14779
15023
 
14780
15024
  const goToPreviewPost = () => {
@@ -14935,6 +15179,20 @@
14935
15179
  doc.getElementById('dc-pv-css').textContent = previewFrameCss();
14936
15180
  doc.body.appendChild(doc.importNode(body, true));
14937
15181
 
15182
+ doc.querySelectorAll('img').forEach(img => {
15183
+ let tries = 0;
15184
+ const original = img.getAttribute('src') || '';
15185
+
15186
+ img.addEventListener('error', () => {
15187
+ if (tries >= 4 || !/dcinside\.(com|co\.kr)\//.test(original)) {
15188
+ return;
15189
+ }
15190
+
15191
+ tries += 1;
15192
+ setTimeout(() => img.setAttribute('src', `${original}&${tries + 1}`), 250 * tries);
15193
+ });
15194
+ });
15195
+
14938
15196
  doc.querySelectorAll('video[data-src]').forEach(video => {
14939
15197
  const gif = video.getAttribute('data-src');
14940
15198
  let swapped = false;
@@ -15198,34 +15456,181 @@
15198
15456
  }
15199
15457
  };
15200
15458
 
15459
+ const PREVIEW_HOLD_MS = 350;
15460
+ let previewMode = 'peek';
15461
+ let previewBackdrop = null;
15462
+
15463
+ const ensurePreviewBackdrop = () => {
15464
+ if (previewBackdrop && previewBackdrop.isConnected) {
15465
+ return previewBackdrop;
15466
+ }
15467
+
15468
+ previewBackdrop = h('div', 'dc-ui');
15469
+ previewBackdrop.id = 'dc-preview-backdrop';
15470
+ previewBackdrop.addEventListener('mousedown', event => {
15471
+ event.preventDefault();
15472
+ event.stopPropagation();
15473
+ closePreview();
15474
+ });
15475
+ document.body.appendChild(previewBackdrop);
15476
+
15477
+ return previewBackdrop;
15478
+ };
15479
+
15480
+ const openPreview = (anchor, mode) => {
15481
+ previewMode = mode;
15482
+
15483
+ const el = ensurePreviewEl();
15484
+
15485
+ el.classList.toggle('dc-pv-peek', mode === 'peek');
15486
+
15487
+ try {
15488
+ const r = anchor.getBoundingClientRect();
15489
+ const ox = Math.round(r.left + Math.min(r.width, 240) / 2 - window.innerWidth / 2);
15490
+ const oy = Math.round(r.top + r.height / 2 - window.innerHeight / 2);
15491
+
15492
+ el.style.setProperty('--dc-pv-from', `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px)) scale(.35)`);
15493
+ } catch {
15494
+ el.style.removeProperty('--dc-pv-from');
15495
+ }
15496
+
15497
+ if (mode === 'peek') {
15498
+ ensurePreviewBackdrop().classList.add('show');
15499
+ } else {
15500
+ previewBackdrop?.classList.remove('show');
15501
+ }
15502
+
15503
+ showPreviewFor(anchor);
15504
+ };
15505
+
15201
15506
  const initHoverPreview = () => {
15202
15507
  if (!IS_PC_SITE) {
15203
15508
  return;
15204
15509
  }
15205
15510
 
15206
- document.addEventListener('mouseover', event => {
15207
- if (!previewEnabled()) {
15208
- return;
15209
- }
15511
+ const hoverOn = () => previewEnabled() && settings.previewHover === true;
15512
+
15513
+ let hoverAnchor = null;
15514
+ let pressedAnchor = null;
15515
+ let holdTimer = null;
15516
+ let holdStart = null;
15517
+ let swallowClick = null;
15210
15518
 
15519
+ document.addEventListener('mousedown', event => {
15211
15520
  const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15212
15521
 
15213
15522
  if (!anchor || anchor.closest('.dc-ui')) {
15523
+ if (previewOpen() && !(event.target instanceof Node && previewEl.contains(event.target))) {
15524
+ closePreview();
15525
+ }
15526
+
15214
15527
  return;
15215
15528
  }
15216
15529
 
15217
- if (anchor === previewAnchor) {
15218
- clearTimeout(previewCloseTimer);
15530
+ pressedAnchor = anchor;
15531
+ clearTimeout(previewHoverTimer);
15532
+
15533
+ if (!previewEnabled() || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
15219
15534
  return;
15220
15535
  }
15221
15536
 
15537
+ holdStart = { x: event.clientX, y: event.clientY };
15538
+ clearTimeout(holdTimer);
15539
+ holdTimer = setTimeout(() => {
15540
+ holdTimer = null;
15541
+ swallowClick = anchor;
15542
+
15543
+ window.getSelection?.()?.removeAllRanges?.();
15544
+ openPreview(anchor, 'peek');
15545
+ }, PREVIEW_HOLD_MS);
15546
+ }, true);
15547
+
15548
+ document.addEventListener('mousemove', event => {
15549
+ if (holdTimer && holdStart && Math.hypot(event.clientX - holdStart.x, event.clientY - holdStart.y) > 8) {
15550
+ clearTimeout(holdTimer);
15551
+ holdTimer = null;
15552
+ }
15553
+ }, { passive: true, capture: true });
15554
+
15555
+ const cancelHold = () => {
15556
+ clearTimeout(holdTimer);
15557
+ holdTimer = null;
15558
+ };
15559
+
15560
+ document.addEventListener('mouseup', cancelHold, true);
15561
+ document.addEventListener('dragstart', event => {
15562
+ if (swallowClick || holdTimer) {
15563
+ event.preventDefault();
15564
+ }
15565
+
15566
+ cancelHold();
15567
+ }, true);
15568
+
15569
+ document.addEventListener('click', event => {
15570
+ if (!swallowClick) {
15571
+ return;
15572
+ }
15573
+
15574
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15575
+
15576
+ if (anchor === swallowClick) {
15577
+ event.preventDefault();
15578
+ event.stopImmediatePropagation();
15579
+ }
15580
+
15581
+ swallowClick = null;
15582
+ }, true);
15583
+
15584
+ const armHover = anchor => {
15222
15585
  clearTimeout(previewHoverTimer);
15586
+
15587
+ if (!hoverOn() || anchor === pressedAnchor || (previewOpen() && previewMode === 'peek')) {
15588
+ return;
15589
+ }
15590
+
15591
+ const switching = previewOpen() && anchor !== previewAnchor;
15592
+
15223
15593
  previewHoverTimer = setTimeout(() => {
15594
+ if (hoverAnchor !== anchor || pressedAnchor === anchor || !anchor.isConnected) {
15595
+ return;
15596
+ }
15597
+
15224
15598
  clearTimeout(previewCloseTimer);
15225
- showPreviewFor(anchor);
15226
- }, PREVIEW_DELAY);
15599
+ openPreview(anchor, 'hover');
15600
+ }, switching ? PREVIEW_SWITCH_DELAY : PREVIEW_DELAY);
15601
+ };
15602
+
15603
+ document.addEventListener('mouseover', event => {
15604
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15605
+
15606
+ if (!anchor || anchor.closest('.dc-ui') || anchor === hoverAnchor) {
15607
+ return;
15608
+ }
15609
+
15610
+ hoverAnchor = anchor;
15611
+
15612
+ if (anchor === previewAnchor && previewOpen()) {
15613
+ clearTimeout(previewCloseTimer);
15614
+ return;
15615
+ }
15616
+
15617
+ if (previewOpen() && previewMode === 'hover') {
15618
+ clearTimeout(previewCloseTimer);
15619
+ }
15620
+
15621
+ armHover(anchor);
15227
15622
  });
15228
15623
 
15624
+ document.addEventListener('mousemove', event => {
15625
+ if (!hoverAnchor || previewAnchor === hoverAnchor) {
15626
+ return;
15627
+ }
15628
+
15629
+ if (event.target instanceof Node && hoverAnchor.contains(event.target)) {
15630
+ armHover(hoverAnchor);
15631
+ }
15632
+ }, { passive: true });
15633
+
15229
15634
  document.addEventListener('mouseout', event => {
15230
15635
  const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15231
15636
 
@@ -15233,21 +15638,23 @@
15233
15638
  return;
15234
15639
  }
15235
15640
 
15641
+ if (hoverAnchor === anchor) {
15642
+ hoverAnchor = null;
15643
+ }
15644
+
15645
+ if (pressedAnchor === anchor) {
15646
+ pressedAnchor = null;
15647
+ }
15648
+
15236
15649
  clearTimeout(previewHoverTimer);
15237
15650
 
15238
- if (previewOpen() && !(event.relatedTarget instanceof Node && previewEl.contains(event.relatedTarget))) {
15651
+ if (previewOpen() && previewMode === 'hover' && !(event.relatedTarget instanceof Node && previewEl.contains(event.relatedTarget))) {
15239
15652
  schedulePreviewClose();
15240
15653
  }
15241
15654
  });
15242
15655
 
15243
- document.addEventListener('mousedown', event => {
15244
- if (previewOpen() && !(event.target instanceof Node && previewEl.contains(event.target))) {
15245
- closePreview();
15246
- }
15247
- }, true);
15248
-
15249
15656
  window.addEventListener('scroll', () => {
15250
- if (previewOpen() && !previewEl.matches(':hover')) {
15657
+ if (previewOpen() && previewMode === 'hover' && !previewEl.matches(':hover')) {
15251
15658
  closePreview();
15252
15659
  }
15253
15660
  }, { passive: true });
@@ -15283,7 +15690,7 @@
15283
15690
  return false;
15284
15691
  }
15285
15692
 
15286
- showPreviewFor(anchor);
15693
+ openPreview(anchor, 'peek');
15287
15694
  return true;
15288
15695
  };
15289
15696
  // =========================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangsicj/dcinsideenhancer",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "DCInside filter & enhancer userscript for mobile and PC (디시인사이드 차단·편의 유저스크립트)",
5
5
  "license": "MIT",
6
6
  "author": "kangsicj",