@kangsicj/dcinsideenhancer 1.1.0 → 1.2.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.
@@ -2,7 +2,7 @@
2
2
  // @name DCInside Enhancer
3
3
  // @name:ko 디시 인핸서
4
4
  // @namespace https://github.com/kangsicj/DCInsideEnhancer
5
- // @version 1.1.0
5
+ // @version 1.2.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.1.0';
78
+ const SCRIPT_VERSION = '1.2.0';
79
79
  const PROJECT_URL = 'https://github.com/kangsicj/DCInsideEnhancer';
80
80
  const DONATE_URL = 'https://fairy.hada.io/@jinny#support';
81
81
 
@@ -107,6 +107,8 @@
107
107
  syncRt: 'dc_sync_rt',
108
108
  recent: 'dc_recent_galls',
109
109
  recentNative: 'dc_recent_native',
110
+ searchHistory: 'dc_search_history',
111
+ blockHits: 'dc_block_hits',
110
112
  alarmSeen: 'dc_alarm_marks',
111
113
  seenVersion: 'dc_seen_version'
112
114
  };
@@ -222,6 +224,9 @@
222
224
  hideSilbeAlarm: true,
223
225
  alarmBubble: true,
224
226
  alarmKeepPolling: true,
227
+ keyboardShortcuts: true,
228
+ hoverPreview: true,
229
+ alarmSystemNotify: false,
225
230
  pcFitWidth: true,
226
231
  autoHideScrollbar: true,
227
232
  scrapIntercept: true,
@@ -275,6 +280,8 @@
275
280
  --dc-type-id: #6d538d;
276
281
  --dc-shadow: 0 8px 24px rgba(0,0,0,.16);
277
282
  --dc-shadow-sm: 0 2px 8px rgba(0,0,0,.12);
283
+ --dc-raised: #ffffff;
284
+ --dc-raised-shadow: 0 1px 2px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.08);
278
285
  --dc-overlay: rgba(0,0,0,.45);
279
286
  --dc-ghost-bg: rgba(128,128,128,.12);
280
287
  --dc-ghost-border: rgba(128,128,128,.38);
@@ -300,6 +307,8 @@
300
307
  --dc-type-id: #9c86b1;
301
308
  --dc-shadow: 0 8px 28px rgba(0,0,0,.55);
302
309
  --dc-shadow-sm: 0 2px 10px rgba(0,0,0,.45);
310
+ --dc-raised: #383b44;
311
+ --dc-raised-shadow: inset 0 0 0 1px rgba(255,255,255,.07), 0 1px 2px rgba(0,0,0,.3);
303
312
  --dc-overlay: rgba(0,0,0,.6);
304
313
  --dc-ghost-bg: rgba(150,150,150,.11);
305
314
  --dc-ghost-border: rgba(150,150,150,.30);
@@ -949,6 +958,10 @@
949
958
 
950
959
  #dc-custom-modal .dc-head-btn {
951
960
  flex: 0 0 auto;
961
+ display: inline-flex;
962
+ align-items: center;
963
+ justify-content: center;
964
+ box-sizing: border-box;
952
965
  height: 30px;
953
966
  padding: 0 12px;
954
967
  border: 1px solid var(--dc-border);
@@ -957,7 +970,7 @@
957
970
  color: var(--dc-text-dim);
958
971
  font-size: 12px;
959
972
  font-weight: 700;
960
- line-height: 28px;
973
+ line-height: 1;
961
974
  cursor: pointer;
962
975
  }
963
976
 
@@ -978,7 +991,15 @@
978
991
 
979
992
  #dc-custom-modal .dc-modal-tab {
980
993
  flex: 1 1 0;
994
+ display: flex;
995
+ align-items: center;
996
+ justify-content: center;
997
+ gap: 3px;
998
+ min-width: 0;
999
+ box-sizing: border-box;
981
1000
  height: 36px;
1001
+ margin: 0;
1002
+ line-height: 1;
982
1003
  padding: 0 2px;
983
1004
  border: none;
984
1005
  border-radius: 9px;
@@ -992,11 +1013,14 @@
992
1013
  }
993
1014
 
994
1015
  #dc-custom-modal .dc-modal-tab.active {
995
- background: var(--dc-surface);
1016
+ background: var(--dc-raised);
996
1017
  color: var(--dc-accent-ink);
997
- box-shadow: var(--dc-shadow-sm);
1018
+ box-shadow: var(--dc-raised-shadow);
998
1019
  }
999
1020
 
1021
+ #dc-custom-modal .dc-modal-tab .dc-tab-ico { flex: 0 0 auto; font-size: 13px; line-height: 1; }
1022
+ #dc-custom-modal .dc-modal-tab .dc-tab-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
1023
+
1000
1024
  #dc-custom-modal .dc-section-title {
1001
1025
  display: flex;
1002
1026
  align-items: center;
@@ -1830,6 +1854,161 @@
1830
1854
 
1831
1855
  #dc-custom-modal .dc-row.dc-row-new .dc-row-title { font-weight: 800; }
1832
1856
 
1857
+ #dc-preview {
1858
+ position: fixed;
1859
+ z-index: 99997;
1860
+ display: flex;
1861
+ flex-direction: column;
1862
+ box-sizing: border-box;
1863
+ border: 1px solid var(--dc-border);
1864
+ border-radius: 14px;
1865
+ background: var(--dc-surface);
1866
+ color: var(--dc-text);
1867
+ box-shadow: var(--dc-shadow);
1868
+ font-size: 14px;
1869
+ line-height: 1.65;
1870
+ opacity: 0;
1871
+ visibility: hidden;
1872
+ pointer-events: none;
1873
+ transform: translateY(4px);
1874
+ transition: opacity .15s ease, transform .15s ease, visibility 0s linear .15s;
1875
+ }
1876
+
1877
+ #dc-preview.show {
1878
+ opacity: 1;
1879
+ visibility: visible;
1880
+ pointer-events: auto;
1881
+ transform: none;
1882
+ transition: opacity .15s ease, transform .15s ease, visibility 0s;
1883
+ }
1884
+
1885
+ #dc-preview .dc-pv-head {
1886
+ flex: 0 0 auto;
1887
+ padding: 14px 16px 10px;
1888
+ border-bottom: 1px solid var(--dc-border);
1889
+ }
1890
+
1891
+ #dc-preview .dc-pv-title {
1892
+ font-size: 15px;
1893
+ font-weight: 800;
1894
+ line-height: 1.4;
1895
+ word-break: break-all;
1896
+ }
1897
+
1898
+ #dc-preview .dc-pv-meta {
1899
+ margin-top: 3px;
1900
+ color: var(--dc-text-dim);
1901
+ font-size: 12px;
1902
+ }
1903
+
1904
+ #dc-preview .dc-pv-actions {
1905
+ display: flex;
1906
+ flex-wrap: wrap;
1907
+ align-items: center;
1908
+ gap: 6px;
1909
+ margin-top: 8px;
1910
+ }
1911
+
1912
+ #dc-preview .dc-btn {
1913
+ display: inline-flex;
1914
+ align-items: center;
1915
+ justify-content: center;
1916
+ box-sizing: border-box;
1917
+ height: 28px;
1918
+ padding: 0 11px;
1919
+ border: 1px solid var(--dc-border);
1920
+ border-radius: 999px;
1921
+ background: var(--dc-surface-2);
1922
+ color: var(--dc-text);
1923
+ font-size: 12px;
1924
+ font-weight: 700;
1925
+ line-height: 1;
1926
+ text-decoration: none;
1927
+ cursor: pointer;
1928
+ }
1929
+
1930
+ #dc-preview .dc-btn:hover { border-color: var(--dc-accent-ink); color: var(--dc-accent-ink); }
1931
+ #dc-preview .dc-btn.danger-ghost { color: var(--dc-danger-ink); }
1932
+ #dc-preview .dc-btn.dc-pv-close { margin-left: auto; width: 28px; padding: 0; }
1933
+
1934
+ #dc-preview .dc-pv-body {
1935
+ flex: 1 1 auto;
1936
+ min-height: 60px;
1937
+ padding: 12px 16px 18px;
1938
+ overflow-y: auto;
1939
+ overscroll-behavior: contain;
1940
+ word-break: break-word;
1941
+ }
1942
+
1943
+ #dc-preview .dc-pv-body img,
1944
+ #dc-preview .dc-pv-body video { max-width: 100% !important; height: auto !important; border-radius: 6px; }
1945
+ #dc-preview .dc-pv-body p { margin: 0 0 4px; }
1946
+ #dc-preview .dc-pv-body { overflow-x: hidden; overflow-wrap: anywhere; }
1947
+ #dc-preview .dc-pv-body .write_div,
1948
+ #dc-preview .dc-pv-body [style*="width"] { width: auto !important; max-width: 100% !important; }
1949
+ #dc-preview .dc-pv-body * { white-space: normal !important; }
1950
+ #dc-preview .dc-pv-body pre, #dc-preview .dc-pv-body pre * { white-space: pre-wrap !important; }
1951
+ #dc-preview .dc-pv-body a { color: var(--dc-accent-ink); }
1952
+ #dc-preview .dc-pv-body table { max-width: 100%; }
1953
+
1954
+ html.dc-dark #dc-preview .dc-pv-body [style*="background"] { background: transparent !important; }
1955
+ html.dc-dark #dc-preview .dc-pv-body [style*="color: rgb(0, 0, 0)"],
1956
+ html.dc-dark #dc-preview .dc-pv-body [style*="color:#000"],
1957
+ html.dc-dark #dc-preview .dc-pv-body [style*="color: #000"],
1958
+ html.dc-dark #dc-preview .dc-pv-body [style*="color:black"],
1959
+ html.dc-dark #dc-preview .dc-pv-body font[color="#000000"] { color: inherit !important; }
1960
+
1961
+ #dc-preview .dc-pv-loading,
1962
+ #dc-preview .dc-pv-note {
1963
+ padding: 10px 0;
1964
+ color: var(--dc-text-dim);
1965
+ font-size: 13px;
1966
+ }
1967
+
1968
+ #dc-preview .dc-pv-loading::before {
1969
+ content: '';
1970
+ display: inline-block;
1971
+ width: 12px;
1972
+ height: 12px;
1973
+ margin-right: 8px;
1974
+ border: 2px solid var(--dc-border);
1975
+ border-top-color: var(--dc-accent-ink);
1976
+ border-radius: 50%;
1977
+ vertical-align: -1px;
1978
+ animation: dc-spin .7s linear infinite;
1979
+ }
1980
+
1981
+ @keyframes dc-spin { to { transform: rotate(360deg); } }
1982
+
1983
+ #dc-preview .dc-pv-warn {
1984
+ display: flex;
1985
+ flex-wrap: wrap;
1986
+ align-items: center;
1987
+ gap: 8px;
1988
+ padding: 10px 12px;
1989
+ border-radius: 10px;
1990
+ background: var(--dc-danger-soft);
1991
+ color: var(--dc-danger-ink);
1992
+ font-size: 13px;
1993
+ font-weight: 700;
1994
+ }
1995
+
1996
+ #dc-preview .dc-pv-blocked {
1997
+ margin: 6px 0;
1998
+ padding: 14px;
1999
+ border: 1px dashed var(--dc-border);
2000
+ border-radius: 8px;
2001
+ color: var(--dc-text-dim);
2002
+ font-size: 12px;
2003
+ text-align: center;
2004
+ }
2005
+
2006
+ #dc-preview .dc-pv-embed { display: inline-block; margin: 6px 0; font-weight: 700; }
2007
+
2008
+ .dc-pv-anchor { text-decoration: underline !important; text-decoration-color: var(--dc-accent-ink) !important; }
2009
+
2010
+ .dc-key-cursor { outline: 2px solid var(--dc-accent-ink) !important; outline-offset: -2px; background: var(--dc-accent-soft) !important; }
2011
+
1833
2012
  #dc-custom-modal .dc-about {
1834
2013
  margin-top: 14px;
1835
2014
  padding: 12px 13px;
@@ -3234,7 +3413,7 @@
3234
3413
 
3235
3414
  window.addEventListener('beforeunload', noteNetLeaving);
3236
3415
 
3237
- document.addEventListener('click', event => {
3416
+ window.addEventListener('click', event => {
3238
3417
  const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
3239
3418
 
3240
3419
  if (!link || event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
@@ -3253,7 +3432,7 @@
3253
3432
  }
3254
3433
  } catch {
3255
3434
  }
3256
- }, true);
3435
+ });
3257
3436
 
3258
3437
  try {
3259
3438
  if (window.navigation && typeof window.navigation.addEventListener === 'function') {
@@ -5823,6 +6002,38 @@
5823
6002
  // =========================================================
5824
6003
 
5825
6004
  let toastTimer = null;
6005
+ const toastListeners = [];
6006
+
6007
+ const notifyToastListeners = () => toastListeners.forEach(fn => {
6008
+ try {
6009
+ fn();
6010
+ } catch {
6011
+ }
6012
+ });
6013
+
6014
+ const placeToast = toast => {
6015
+ toast.style.bottom = '';
6016
+
6017
+ const fab = document.getElementById('dc-custom-fab');
6018
+
6019
+ if (!fab || getComputedStyle(fab).display === 'none') {
6020
+ return;
6021
+ }
6022
+
6023
+ const f = fab.getBoundingClientRect();
6024
+ const t = toast.getBoundingClientRect();
6025
+
6026
+ if (!f.width || !t.width) {
6027
+ return;
6028
+ }
6029
+
6030
+ const overlapX = t.left < f.right + 6 && t.right > f.left - 6;
6031
+ const overlapY = t.top < f.bottom + 6 && t.bottom > f.top - 6;
6032
+
6033
+ if (overlapX && overlapY) {
6034
+ toast.style.bottom = `${Math.round(window.innerHeight - f.top + 10)}px`;
6035
+ }
6036
+ };
5826
6037
 
5827
6038
  const showToast = (text, { actionLabel = '', onAction = null, duration = 3500 } = {}) => {
5828
6039
  let toast = document.getElementById('dc-toast');
@@ -5851,8 +6062,13 @@
5851
6062
  };
5852
6063
 
5853
6064
  toast.classList.add('show');
6065
+ placeToast(toast);
6066
+ notifyToastListeners();
5854
6067
  clearTimeout(toastTimer);
5855
- toastTimer = setTimeout(() => toast.classList.remove('show'), duration);
6068
+ toastTimer = setTimeout(() => {
6069
+ toast.classList.remove('show');
6070
+ setTimeout(notifyToastListeners, 220);
6071
+ }, duration);
5856
6072
  };
5857
6073
 
5858
6074
  const offerUndo = (text, undo) => {
@@ -6402,7 +6618,97 @@
6402
6618
 
6403
6619
  const currentPostKey = () => extractPostPath(location.href) || location.pathname;
6404
6620
 
6405
- const computeBlockReason = el => {
6621
+ const HIT_DAY_MS = 86400000;
6622
+ const hitToday = () => Math.floor(Date.now() / HIT_DAY_MS);
6623
+
6624
+ const readBlockHits = () => {
6625
+ const stored = readObject(STORAGE.blockHits);
6626
+
6627
+ return {
6628
+ since: Number(stored.since) || hitToday(),
6629
+ k: stored.k && typeof stored.k === 'object' && !Array.isArray(stored.k) ? { ...stored.k } : {}
6630
+ };
6631
+ };
6632
+
6633
+ let blockHits = readBlockHits();
6634
+ let blockHitsDirty = !readObject(STORAGE.blockHits).since;
6635
+ let blockHitsTimer = null;
6636
+
6637
+ const blockHitKeys = () => [
6638
+ ...blockedUsers,
6639
+ ...blockedTitles.map(word => `t:${word}`),
6640
+ ...blockedKeywords.map(word => `w:${word}`)
6641
+ ];
6642
+
6643
+ const flushBlockHits = () => {
6644
+ clearTimeout(blockHitsTimer);
6645
+ blockHitsTimer = null;
6646
+
6647
+ if (!blockHitsDirty) {
6648
+ return;
6649
+ }
6650
+
6651
+ blockHitsDirty = false;
6652
+
6653
+ const today = hitToday();
6654
+ const next = {};
6655
+
6656
+ blockHitKeys().forEach(key => {
6657
+ next[key] = Number(blockHits.k[key]) || today;
6658
+ });
6659
+
6660
+ blockHits = { since: blockHits.since, k: next };
6661
+ writeJson(STORAGE.blockHits, blockHits);
6662
+ };
6663
+
6664
+ const scheduleBlockHitsFlush = () => {
6665
+ blockHitsDirty = true;
6666
+
6667
+ if (!blockHitsTimer) {
6668
+ blockHitsTimer = setTimeout(flushBlockHits, 20000);
6669
+ }
6670
+ };
6671
+
6672
+ const noteBlockHit = reason => {
6673
+ if (!reason) {
6674
+ return reason;
6675
+ }
6676
+
6677
+ const keys = reason.type === 'user'
6678
+ ? reason.userKeys || []
6679
+ : reason.type === 'title'
6680
+ ? [`t:${reason.word}`]
6681
+ : reason.type === 'keyword'
6682
+ ? [blockedTitles.includes(reason.word) && !blockedKeywords.includes(reason.word) ? `t:${reason.word}` : `w:${reason.word}`]
6683
+ : [];
6684
+ const today = hitToday();
6685
+
6686
+ keys.forEach(key => {
6687
+ if (blockHits.k[key] !== today) {
6688
+ blockHits.k[key] = today;
6689
+ scheduleBlockHitsFlush();
6690
+ }
6691
+ });
6692
+
6693
+ return reason;
6694
+ };
6695
+
6696
+ window.addEventListener('pagehide', flushBlockHits);
6697
+ document.addEventListener('visibilitychange', () => {
6698
+ if (document.visibilityState === 'hidden') {
6699
+ flushBlockHits();
6700
+ }
6701
+ });
6702
+
6703
+ storageWriteListeners.push(key => {
6704
+ if (key === STORAGE.users || key === STORAGE.keywords || key === STORAGE.titles) {
6705
+ scheduleBlockHitsFlush();
6706
+ }
6707
+ });
6708
+
6709
+ const computeBlockReason = el => noteBlockHit(computeBlockReasonRaw(el));
6710
+
6711
+ const computeBlockReasonRaw = el => {
6406
6712
  const hits = buildBlockKeys({
6407
6713
  nickname: el.dataset.dcNick || '',
6408
6714
  ip: el.dataset.dcIp || '',
@@ -9927,6 +10233,7 @@
9927
10233
  });
9928
10234
 
9929
10235
  area.appendChild(seg);
10236
+ renderCleanupHelper(area);
9930
10237
  (renderBlockSections[blockSection] || renderBlockSections.keywords)(area);
9931
10238
  };
9932
10239
 
@@ -10113,6 +10420,15 @@
10113
10420
  off: '사이트 기본 한 줄 알림을 그대로 씁니다.',
10114
10421
  refresh: 'root'
10115
10422
  },
10423
+ {
10424
+ group: 'alarm',
10425
+ key: 'alarmSystemNotify',
10426
+ parent: 'alarmBubble',
10427
+ name: '다른 탭을 볼 때 기기 알림으로도 (PC)',
10428
+ on: '이 탭을 보고 있지 않을 때 디시 알림이 오면 윈도우·맥 알림으로 띄웁니다. 누르면 이 탭으로 돌아와 해당 글로 갑니다. 처음 켤 때 브라우저가 권한을 묻습니다.',
10429
+ off: '이 탭을 볼 때 말풍선으로만 알립니다.',
10430
+ refresh: 'root'
10431
+ },
10116
10432
  {
10117
10433
  group: 'alarm',
10118
10434
  key: 'alarmKeepPolling',
@@ -10138,6 +10454,22 @@
10138
10454
  off: '모든 댓글을 보여 줍니다.',
10139
10455
  refresh: 'all'
10140
10456
  },
10457
+ {
10458
+ group: 'list',
10459
+ key: 'keyboardShortcuts',
10460
+ name: '키보드 단축키 (PC)',
10461
+ on: '목록에서 J/K 로 글을 고르고 Enter 로 엽니다. 글에서는 J/K 로 다음·이전 글, S 스크랩, / 검색창, B 차단 관리, ? 안내.',
10462
+ off: '단축키를 쓰지 않습니다.',
10463
+ refresh: 'none'
10464
+ },
10465
+ {
10466
+ group: 'list',
10467
+ key: 'hoverPreview',
10468
+ name: '제목에 마우스를 올리면 글 미리보기 (PC)',
10469
+ on: '목록 제목 위에 잠깐 머무르면 글 전체를 옆 창에 보여 줍니다. 창 안에서 스크롤해 읽고, 스크랩·작성자 차단도 바로 할 수 있습니다. 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10470
+ off: '제목을 눌러야 글을 봅니다.',
10471
+ refresh: 'root'
10472
+ },
10141
10473
  {
10142
10474
  group: 'list',
10143
10475
  key: 'autoFillList',
@@ -10335,6 +10667,20 @@
10335
10667
  paintAlarmBadge();
10336
10668
  }
10337
10669
 
10670
+ if (item.key === 'hoverPreview' && settings.hoverPreview === false) {
10671
+ closePreview();
10672
+ }
10673
+
10674
+ if (item.key === 'alarmSystemNotify' && settings.alarmSystemNotify === true) {
10675
+ requestSystemNotify().then(granted => {
10676
+ if (!granted) {
10677
+ settings.alarmSystemNotify = false;
10678
+ saveSettings();
10679
+ rerenderModal();
10680
+ }
10681
+ });
10682
+ }
10683
+
10338
10684
  break;
10339
10685
 
10340
10686
  case 'log':
@@ -11295,7 +11641,11 @@
11295
11641
  '<button id="dc-modal-close-btn" class="dc-head-btn" type="button">닫기</button>' +
11296
11642
  '</div>' +
11297
11643
  `<div class="dc-modal-tabs">${Object.keys(MODAL_VIEWS)
11298
- .map(key => `<button class="dc-modal-tab" type="button" data-dc-view="${key}">${MODAL_VIEWS[key].tab}</button>`)
11644
+ .map(key => {
11645
+ const [ico, ...rest] = MODAL_VIEWS[key].tab.split(' ');
11646
+
11647
+ return `<button class="dc-modal-tab" type="button" data-dc-view="${key}"><span class="dc-tab-ico" aria-hidden="true">${ico}</span><span class="dc-tab-txt">${rest.join(' ')}</span></button>`;
11648
+ })
11299
11649
  .join('')}</div>` +
11300
11650
  '</div>' +
11301
11651
  '<div id="dc-modal-content"></div>';
@@ -11385,6 +11735,7 @@
11385
11735
  { key: 'heads', label: '말머리', fields: ['heads'] },
11386
11736
  { key: 'scraps', label: '스크랩', fields: ['scraps'] },
11387
11737
  { key: 'recent', label: '최근 갤러리', fields: ['recent'] },
11738
+ { key: 'search', label: '검색 기록', fields: ['search'] },
11388
11739
  { key: 'settings', label: '설정', fields: ['settings'] }
11389
11740
  ];
11390
11741
 
@@ -11552,7 +11903,9 @@
11552
11903
  heads: JSON.parse(JSON.stringify(hiddenHeadMap || {})),
11553
11904
  scraps: scraps.map(item => ({ ...item })),
11554
11905
  settings: syncedSettings(),
11555
- recent: collectRecentForSync()
11906
+ recent: collectRecentForSync(),
11907
+ search: collectSearchForSync(),
11908
+ hits: collectHitsForSync()
11556
11909
  });
11557
11910
 
11558
11911
  const skippedFields = skip => SYNC_PARTS.filter(part => skip.includes(part.key)).flatMap(part => part.fields);
@@ -11659,7 +12012,9 @@
11659
12012
  heads: mergeHeads(local.heads, base && base.heads, remote && remote.heads),
11660
12013
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
11661
12014
  settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
11662
- recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent)
12015
+ recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
12016
+ search: mergeSearchHistory(local.search, base && base.search, remote && remote.search),
12017
+ hits: mergeBlockHits(local.hits, remote && remote.hits)
11663
12018
  };
11664
12019
 
11665
12020
  skippedFields(skip).forEach(field => {
@@ -11713,12 +12068,16 @@
11713
12068
  const data = collectSyncData();
11714
12069
 
11715
12070
  delete data.recent;
12071
+ delete data.search;
12072
+ delete data.hits;
11716
12073
 
11717
12074
  return JSON.stringify(data);
11718
12075
  };
11719
12076
 
11720
12077
  const applySyncData = data => {
11721
12078
  const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
12079
+ const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
12080
+ const hitsChanged = data && 'hits' in data ? applyHitsFromSync(data.hits) : false;
11722
12081
  const clean = sanitizeBackup({ app: BACKUP_APP, ...data });
11723
12082
  const before = blockSyncData();
11724
12083
 
@@ -11773,7 +12132,7 @@
11773
12132
  refreshAfterBlockChange();
11774
12133
  }
11775
12134
 
11776
- return changed || recentChanged;
12135
+ return changed || recentChanged || searchChanged || hitsChanged;
11777
12136
  };
11778
12137
 
11779
12138
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -13134,8 +13493,12 @@
13134
13493
  const nativeKeys = new Set(native.map(recentKeyOf));
13135
13494
  const snapshot = readArray(STORAGE.recentNative).filter(key => typeof key === 'string');
13136
13495
 
13137
- if (snapshot.length && native.length < RECENT_NATIVE_CAP - 1) {
13138
- const removed = new Set(snapshot.filter(key => !nativeKeys.has(key)));
13496
+ if (snapshot.length) {
13497
+ const snapSet = new Set(snapshot);
13498
+ const added = native.filter(item => !snapSet.has(recentKeyOf(item))).length;
13499
+ const overflow = Math.max(0, snapshot.length + added - RECENT_NATIVE_CAP);
13500
+ const kept = snapshot.slice(0, snapshot.length - overflow);
13501
+ const removed = new Set(kept.filter(key => !nativeKeys.has(key)));
13139
13502
 
13140
13503
  if (removed.size) {
13141
13504
  recentGalls = recentGalls.filter(item => !removed.has(recentKeyOf(item)));
@@ -13489,24 +13852,40 @@
13489
13852
  }
13490
13853
 
13491
13854
  const fab = document.getElementById('dc-custom-fab');
13492
- const rect = fab && fab.offsetParent !== null ? fab.getBoundingClientRect() : null;
13855
+ const rect = fab && getComputedStyle(fab).display !== 'none' ? fab.getBoundingClientRect() : null;
13493
13856
  const vw = window.innerWidth;
13494
13857
  const vh = window.innerHeight;
13858
+ let bottom = 144;
13495
13859
 
13496
13860
  if (rect && rect.width) {
13497
13861
  const onRight = rect.left + rect.width / 2 > vw / 2;
13498
13862
 
13499
13863
  bubbleBox.style.left = onRight ? '' : `${Math.max(8, rect.left)}px`;
13500
13864
  bubbleBox.style.right = onRight ? `${Math.max(8, vw - rect.right)}px` : '';
13501
- bubbleBox.style.bottom = `${Math.max(8, vh - rect.top + 10)}px`;
13865
+ bottom = Math.max(8, vh - rect.top + 10);
13502
13866
  bubbleBox.classList.toggle('dc-left', !onRight);
13503
13867
  } else {
13504
13868
  bubbleBox.style.left = '';
13505
13869
  bubbleBox.style.right = '15px';
13506
- bubbleBox.style.bottom = 'calc(144px + env(safe-area-inset-bottom))';
13870
+ bubbleBox.classList.remove('dc-left');
13871
+ }
13872
+
13873
+ const toast = document.getElementById('dc-toast');
13874
+
13875
+ if (toast && toast.classList.contains('show')) {
13876
+ const t = toast.getBoundingClientRect();
13877
+ const box = bubbleBox.getBoundingClientRect();
13878
+
13879
+ if (t.width && t.left < box.right && t.right > box.left) {
13880
+ bottom = Math.max(bottom, vh - t.top + 8);
13881
+ }
13507
13882
  }
13883
+
13884
+ bubbleBox.style.bottom = `${Math.round(bottom)}px`;
13508
13885
  };
13509
13886
 
13887
+ toastListeners.push(() => placeBubbleBox());
13888
+
13510
13889
  const ensureBubbleBox = () => {
13511
13890
  if (bubbleBox && bubbleBox.isConnected) {
13512
13891
  return bubbleBox;
@@ -13654,7 +14033,9 @@
13654
14033
  let alarmWatchTimer = null;
13655
14034
 
13656
14035
  const checkSiteAlarms = () => {
13657
- if (settings.alarmBubble === false || document.visibilityState === 'hidden') {
14036
+ const away = document.visibilityState === 'hidden' || (typeof document.hasFocus === 'function' && !document.hasFocus());
14037
+
14038
+ if (settings.alarmBubble === false || (document.visibilityState === 'hidden' && !canSystemNotify())) {
13658
14039
  return;
13659
14040
  }
13660
14041
 
@@ -13682,7 +14063,11 @@
13682
14063
  writeAlarmMarks({ shown: latest });
13683
14064
  }
13684
14065
 
13685
- if (fresh.length) {
14066
+ if (fresh.length && away && canSystemNotify()) {
14067
+ showSystemAlarm(fresh[0], fresh.length - 1);
14068
+ }
14069
+
14070
+ if (fresh.length && document.visibilityState !== 'hidden') {
13686
14071
  showAlarmBubble(fresh[0], fresh.length > ALARM_BUBBLE_MAX ? fresh.length - 1 : 0);
13687
14072
 
13688
14073
  if (fresh.length > 1 && fresh.length <= ALARM_BUBBLE_MAX) {
@@ -13730,9 +14115,24 @@
13730
14115
  }
13731
14116
  };
13732
14117
 
13733
- const initSiteAlarms = () => {
13734
- if (alarmWatchTimer || !alarmStorageKeys().length && !siteUserId()) {
14118
+ function initSiteAlarms() {
14119
+ if (alarmWatchTimer || !(IS_MOBILE_SITE || /^gall\.dcinside\.com$/i.test(location.hostname))) {
14120
+ paintAlarmBadge();
14121
+ return;
14122
+ }
14123
+
14124
+ if (!siteUserId() && !alarmStorageKeys().length) {
13735
14125
  paintAlarmBadge();
14126
+
14127
+ if (!initSiteAlarms.retried) {
14128
+ initSiteAlarms.retried = true;
14129
+ [3000, 10000].forEach(delay => setTimeout(() => {
14130
+ if (!alarmWatchTimer && (siteUserId() || alarmStorageKeys().length)) {
14131
+ initSiteAlarms();
14132
+ }
14133
+ }, delay));
14134
+ }
14135
+
13736
14136
  return;
13737
14137
  }
13738
14138
 
@@ -13873,6 +14273,889 @@
13873
14273
  })();
13874
14274
 
13875
14275
  initRecentEarly();
14276
+
14277
+ // =========================================================
14278
+
14279
+ // =========================================================
14280
+
14281
+ const KEY_HELP = 'J/K 다음·이전 글 · Enter 열기 · P 미리보기 · S 스크랩 · / 검색 · B 차단 관리';
14282
+
14283
+ const typingTarget = el =>
14284
+ !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || !!el.closest?.('[contenteditable="true"]'));
14285
+
14286
+ const visibleListRows = () => {
14287
+ const listEl = pickListElement(document);
14288
+
14289
+ if (!listEl) {
14290
+ return [];
14291
+ }
14292
+
14293
+ return realListItems(listEl).filter(row => row.getClientRects().length && listItemKey(row) && !row.closest('.dc-ui'));
14294
+ };
14295
+
14296
+ let keyCursor = null;
14297
+
14298
+ const moveKeyCursor = step => {
14299
+ const rows = visibleListRows();
14300
+
14301
+ if (!rows.length) {
14302
+ return false;
14303
+ }
14304
+
14305
+ const here = currentPostKey();
14306
+ let index = keyCursor ? rows.indexOf(keyCursor) : -1;
14307
+
14308
+ if (currentPostInfo()) {
14309
+ const at = rows.findIndex(row => listItemKey(row) === here);
14310
+ const target = at >= 0 ? rows[at + step] : step > 0 ? rows[0] : null;
14311
+ const link = target && target.querySelector(POST_LINK_SELECTOR);
14312
+
14313
+ if (link) {
14314
+ location.href = link.href;
14315
+ return true;
14316
+ }
14317
+
14318
+ showToast(step > 0 ? '아래에 더 볼 글이 없습니다' : '첫 글입니다');
14319
+ return true;
14320
+ }
14321
+
14322
+ if (index < 0) {
14323
+ index = rows.findIndex(row => row.getBoundingClientRect().bottom > 60);
14324
+ index = Math.max(0, step > 0 ? index : index);
14325
+ } else {
14326
+ index = Math.min(rows.length - 1, Math.max(0, index + step));
14327
+ }
14328
+
14329
+ keyCursor?.classList.remove('dc-key-cursor');
14330
+ keyCursor = rows[index];
14331
+ keyCursor.classList.add('dc-key-cursor');
14332
+ keyCursor.scrollIntoView({ block: 'nearest' });
14333
+
14334
+ return true;
14335
+ };
14336
+
14337
+ const initKeyboardShortcuts = () => {
14338
+ document.addEventListener('keydown', event => {
14339
+ if (settings.keyboardShortcuts === false || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) {
14340
+ return;
14341
+ }
14342
+
14343
+ if (typingTarget(event.target) || typingTarget(document.activeElement)) {
14344
+ return;
14345
+ }
14346
+
14347
+ if (document.getElementById('dc-custom-modal')?.classList.contains('show')) {
14348
+ return;
14349
+ }
14350
+
14351
+ const key = event.key;
14352
+ let handled = false;
14353
+
14354
+ if (key === 'j' || key === 'J') {
14355
+ handled = moveKeyCursor(1);
14356
+ } else if (key === 'k' || key === 'K') {
14357
+ handled = moveKeyCursor(-1);
14358
+ } else if ((key === 'Enter' || key === 'o' || key === 'O') && keyCursor && keyCursor.isConnected) {
14359
+ const link = keyCursor.querySelector(POST_LINK_SELECTOR);
14360
+
14361
+ if (link) {
14362
+ location.href = link.href;
14363
+ handled = true;
14364
+ }
14365
+ } else if (key === 's' || key === 'S') {
14366
+ const info = currentPostInfo();
14367
+
14368
+ if (info) {
14369
+ toggleScrap(info);
14370
+ handled = true;
14371
+ }
14372
+ } else if (key === '/') {
14373
+ const box = document.querySelector('.in_keyword, #preSWord, input.js-keyword, input[name="search_keyword"], .top_search input[type="text"]');
14374
+
14375
+ if (box) {
14376
+ box.focus();
14377
+ handled = true;
14378
+ }
14379
+ } else if (key === 'b' || key === 'B') {
14380
+ openBlockModal(document.getElementById('dc-custom-fab')?.dataset.dcAlarm ? 'recent' : 'overview');
14381
+ handled = true;
14382
+ } else if ((key === 'p' || key === 'P') && togglePreviewForCursor()) {
14383
+ handled = true;
14384
+ } else if (key === '?') {
14385
+ showToast(`⌨️ ${KEY_HELP}`, { duration: 6000 });
14386
+ handled = true;
14387
+ }
14388
+
14389
+ if (handled) {
14390
+ event.preventDefault();
14391
+ }
14392
+ });
14393
+ };
14394
+
14395
+ // =========================================================
14396
+
14397
+ // =========================================================
14398
+
14399
+ const SEARCH_HISTORY_MAX = 30;
14400
+
14401
+ const readSiteSearchHistory = () => {
14402
+ if (!IS_MOBILE_SITE) {
14403
+ return null;
14404
+ }
14405
+
14406
+ try {
14407
+ const list = JSON.parse(localStorage.getItem('searchHistory') || '[]');
14408
+
14409
+ return Array.isArray(list) ? list.filter(item => typeof item === 'string' && item.trim()).map(item => item.slice(0, 60)) : [];
14410
+ } catch {
14411
+ return [];
14412
+ }
14413
+ };
14414
+
14415
+ const collectSearchForSync = () => {
14416
+ const site = readSiteSearchHistory();
14417
+ const list = site || readArray(STORAGE.searchHistory).filter(item => typeof item === 'string');
14418
+
14419
+ return [...new Set(list)].slice(-SEARCH_HISTORY_MAX);
14420
+ };
14421
+
14422
+ const mergeSearchHistory = (local = [], base, remote) => {
14423
+ if (!Array.isArray(remote)) {
14424
+ return [...new Set(local || [])].slice(-SEARCH_HISTORY_MAX);
14425
+ }
14426
+
14427
+ return mergeList(local || [], Array.isArray(base) ? base : null, remote).slice(-SEARCH_HISTORY_MAX);
14428
+ };
14429
+
14430
+ const applySearchFromSync = list => {
14431
+ if (!Array.isArray(list)) {
14432
+ return false;
14433
+ }
14434
+
14435
+ const next = [...new Set(list.filter(item => typeof item === 'string'))].slice(-SEARCH_HISTORY_MAX);
14436
+
14437
+ if (JSON.stringify(next) === JSON.stringify(collectSearchForSync())) {
14438
+ return false;
14439
+ }
14440
+
14441
+ writeJson(STORAGE.searchHistory, next);
14442
+
14443
+ if (IS_MOBILE_SITE) {
14444
+ try {
14445
+ localStorage.setItem('searchHistory', JSON.stringify(next));
14446
+ } catch {
14447
+ }
14448
+ }
14449
+
14450
+ return true;
14451
+ };
14452
+
14453
+ // =========================================================
14454
+ // 13-4. 차단 목록 정리 도우미 · 기기 알림 · PC 글 미리보기
14455
+ // =========================================================
14456
+
14457
+ const collectHitsForSync = () => {
14458
+ flushBlockHits();
14459
+
14460
+ return { since: blockHits.since, k: { ...blockHits.k } };
14461
+ };
14462
+
14463
+ const mergeBlockHits = (local, remote) => {
14464
+ if (!remote || typeof remote !== 'object' || !remote.k) {
14465
+ return local;
14466
+ }
14467
+
14468
+ const k = { ...(remote.k || {}) };
14469
+
14470
+ Object.entries((local && local.k) || {}).forEach(([key, day]) => {
14471
+ k[key] = Math.max(Number(k[key]) || 0, Number(day) || 0);
14472
+ });
14473
+
14474
+ return { since: Math.min(Number(local && local.since) || hitToday(), Number(remote.since) || hitToday()), k };
14475
+ };
14476
+
14477
+ const applyHitsFromSync = data => {
14478
+ if (!data || typeof data !== 'object' || !data.k) {
14479
+ return false;
14480
+ }
14481
+
14482
+ const merged = mergeBlockHits(blockHits, data);
14483
+
14484
+ if (JSON.stringify(merged) === JSON.stringify(blockHits)) {
14485
+ return false;
14486
+ }
14487
+
14488
+ blockHits = merged;
14489
+ blockHitsDirty = true;
14490
+ flushBlockHits();
14491
+
14492
+ return true;
14493
+ };
14494
+
14495
+ // =========================================================
14496
+
14497
+ // =========================================================
14498
+
14499
+ const CLEANUP_IDLE_DAYS = 60;
14500
+
14501
+ const cleanupSuggestions = () => {
14502
+ flushBlockHits();
14503
+
14504
+ const today = hitToday();
14505
+ const tracked = today - blockHits.since;
14506
+ const idle = [];
14507
+
14508
+ if (tracked >= CLEANUP_IDLE_DAYS) {
14509
+ blockHitKeys().forEach(key => {
14510
+ const day = Number(blockHits.k[key]) || today;
14511
+
14512
+ if (today - day >= CLEANUP_IDLE_DAYS) {
14513
+ idle.push({ key, days: today - day });
14514
+ }
14515
+ });
14516
+ }
14517
+
14518
+ const redundant = [];
14519
+
14520
+ blockedKeywords.forEach(word => {
14521
+ const cover = blockedKeywords.find(other => other !== word && other.length < word.length && word.includes(other));
14522
+
14523
+ if (cover) {
14524
+ redundant.push({ key: `w:${word}`, cover });
14525
+ }
14526
+ });
14527
+
14528
+ blockedTitles.forEach(word => {
14529
+ const cover = [...blockedKeywords, ...blockedTitles].find(other => other !== word && other.length < word.length && word.includes(other));
14530
+
14531
+ if (cover) {
14532
+ redundant.push({ key: `t:${word}`, cover });
14533
+ }
14534
+ });
14535
+
14536
+ return { idle: idle.sort((a, b) => b.days - a.days), redundant, tracked };
14537
+ };
14538
+
14539
+ const describeHitKey = key => {
14540
+ if (key.startsWith('w:')) {
14541
+ return { label: key.slice(2), kind: '단어' };
14542
+ }
14543
+
14544
+ if (key.startsWith('t:')) {
14545
+ return { label: key.slice(2), kind: '제목 단어' };
14546
+ }
14547
+
14548
+ const parsed = parseBlockKey(key);
14549
+
14550
+ return { label: parsed.value, kind: BLOCK_TYPE_LABELS[parsed.type] || '유저' };
14551
+ };
14552
+
14553
+ const removeBlockKeys = keys => {
14554
+ const before = { users: [...blockedUsers], keywords: [...blockedKeywords], titles: [...blockedTitles] };
14555
+ const drop = new Set(keys);
14556
+
14557
+ blockedUsers = blockedUsers.filter(key => !drop.has(key));
14558
+ blockedKeywords = blockedKeywords.filter(word => !drop.has(`w:${word}`));
14559
+ blockedTitles = blockedTitles.filter(word => !drop.has(`t:${word}`));
14560
+ saveUsers();
14561
+ saveKeywords();
14562
+ saveTitles();
14563
+ refreshAfterBlockChange();
14564
+
14565
+ showToast(`🧹 ${keys.length}개 정리함`, {
14566
+ actionLabel: '되돌리기',
14567
+ duration: 6000,
14568
+ onAction: () => {
14569
+ blockedUsers = before.users;
14570
+ blockedKeywords = before.keywords;
14571
+ blockedTitles = before.titles;
14572
+ saveUsers();
14573
+ saveKeywords();
14574
+ saveTitles();
14575
+ refreshAfterBlockChange();
14576
+ rerenderModal();
14577
+ }
14578
+ });
14579
+ };
14580
+
14581
+ const renderCleanupHelper = area => {
14582
+ const { idle, redundant, tracked } = cleanupSuggestions();
14583
+ const count = idle.length + redundant.length;
14584
+ const waitDays = Math.max(0, CLEANUP_IDLE_DAYS - tracked);
14585
+
14586
+ if (!count && !waitDays) {
14587
+ return;
14588
+ }
14589
+
14590
+ area.appendChild(makeFoldSection({
14591
+ id: 'cleanup',
14592
+ title: `🧹 정리 도우미${count ? '' : ' · 준비 중'}`,
14593
+ count,
14594
+ defaultOpen: false,
14595
+ render: body => {
14596
+ if (waitDays) {
14597
+ body.appendChild(makeNote(`어떤 차단이 실제로 쓰이는지 기록을 모으는 중입니다. ${waitDays}일 뒤부터 오래 안 걸린 항목을 추천합니다.`));
14598
+ }
14599
+
14600
+ const group = (title, items, meta) => {
14601
+ if (!items.length) {
14602
+ return;
14603
+ }
14604
+
14605
+ body.appendChild(makeSectionTitle(`${title} · ${items.length}개`));
14606
+ renderPaged(body, items, item => {
14607
+ const info = describeHitKey(item.key);
14608
+
14609
+ return makeRow({
14610
+ title: info.label,
14611
+ chip: info.kind,
14612
+ chipClass: 'scope',
14613
+ meta: meta(item),
14614
+ actions: [{ label: '해제', className: 'on', onClick: () => {
14615
+ removeBlockKeys([item.key]);
14616
+ rerenderModal();
14617
+ } }]
14618
+ });
14619
+ });
14620
+ body.appendChild(makeButton(`${title} 모두 해제`, 'wide', () => {
14621
+ if (confirm(`${title} ${items.length}개를 차단 목록에서 뺄까요? (바로 되돌릴 수 있습니다)`)) {
14622
+ removeBlockKeys(items.map(item => item.key));
14623
+ rerenderModal();
14624
+ }
14625
+ }));
14626
+ };
14627
+
14628
+ group('필요 없는 단어', redundant, item => `"${item.cover}" 가 이미 걸러 줌`);
14629
+ group(`${CLEANUP_IDLE_DAYS}일 넘게 안 걸린 항목`, idle, item => `${item.days}일 동안 걸린 글·댓글 없음 (모든 기기)`);
14630
+ }
14631
+ }));
14632
+ };
14633
+
14634
+ // =========================================================
14635
+
14636
+ // =========================================================
14637
+
14638
+ const systemNotifySupported = () => typeof window.Notification === 'function';
14639
+
14640
+ const canSystemNotify = () =>
14641
+ settings.alarmSystemNotify === true && systemNotifySupported() && window.Notification.permission === 'granted';
14642
+
14643
+ const showSystemAlarm = (item, extra = 0) => {
14644
+ try {
14645
+ const note = new window.Notification(`디시 알림 · ${item.tag}`, {
14646
+ body: extra ? `${item.text}\n외 ${extra}개` : item.text,
14647
+ tag: `dcx-${item.key}`,
14648
+ silent: false
14649
+ });
14650
+
14651
+ note.onclick = () => {
14652
+ try {
14653
+ window.focus();
14654
+ } catch {
14655
+ }
14656
+
14657
+ note.close();
14658
+
14659
+ if (extra) {
14660
+ openBlockModal('recent');
14661
+ } else {
14662
+ openAlarm(item);
14663
+ }
14664
+ };
14665
+ } catch {
14666
+ }
14667
+ };
14668
+
14669
+ const requestSystemNotify = async () => {
14670
+ if (!systemNotifySupported()) {
14671
+ showToast('이 브라우저는 기기 알림을 지원하지 않습니다');
14672
+ return false;
14673
+ }
14674
+
14675
+ let permission = window.Notification.permission;
14676
+
14677
+ if (permission === 'default') {
14678
+ try {
14679
+ permission = await window.Notification.requestPermission();
14680
+ } catch {
14681
+ permission = 'denied';
14682
+ }
14683
+ }
14684
+
14685
+ if (permission !== 'granted') {
14686
+ showToast('🔕 브라우저에서 이 사이트의 알림을 허용해야 기기 알림을 띄울 수 있습니다', { duration: 6000 });
14687
+ return false;
14688
+ }
14689
+
14690
+ showToast('🔔 다른 탭을 보고 있어도 디시 알림을 기기 알림으로 띄웁니다');
14691
+ return true;
14692
+ };
14693
+
14694
+ // =========================================================
14695
+
14696
+ // =========================================================
14697
+
14698
+ const PREVIEW_DELAY = 450;
14699
+ const PREVIEW_CACHE_MS = 5 * 60 * 1000;
14700
+ const PREVIEW_CACHE_MAX = 30;
14701
+ const PREVIEW_TITLE_SELECTOR = 'table.gall_list td.gall_tit > a:not(.reply_numbox)';
14702
+
14703
+ const previewCache = new Map();
14704
+ let previewEl = null;
14705
+ let previewKey = '';
14706
+ let previewAnchor = null;
14707
+ let previewHoverTimer = null;
14708
+ let previewCloseTimer = null;
14709
+
14710
+ const previewEnabled = () => IS_PC_SITE && settings.hoverPreview !== false;
14711
+
14712
+ const ensurePreviewEl = () => {
14713
+ if (previewEl && previewEl.isConnected) {
14714
+ return previewEl;
14715
+ }
14716
+
14717
+ previewEl = h('div', 'dc-ui');
14718
+ previewEl.id = 'dc-preview';
14719
+ previewEl.setAttribute('role', 'dialog');
14720
+ previewEl.setAttribute('aria-label', '글 미리보기');
14721
+ previewEl.innerHTML =
14722
+ '<div class="dc-pv-head">' +
14723
+ '<div class="dc-pv-title"></div>' +
14724
+ '<div class="dc-pv-meta"></div>' +
14725
+ '<div class="dc-pv-actions"></div>' +
14726
+ '</div>' +
14727
+ '<div class="dc-pv-body"></div>';
14728
+ previewEl.addEventListener('mouseenter', () => clearTimeout(previewCloseTimer));
14729
+ previewEl.addEventListener('mouseleave', () => schedulePreviewClose());
14730
+ document.body.appendChild(previewEl);
14731
+
14732
+ return previewEl;
14733
+ };
14734
+
14735
+ const placePreview = anchor => {
14736
+ const el = ensurePreviewEl();
14737
+ const rect = anchor.getBoundingClientRect();
14738
+ const vw = window.innerWidth;
14739
+ const vh = window.innerHeight;
14740
+ const width = Math.min(640, Math.max(360, Math.round(vw * 0.42)));
14741
+ const height = Math.min(Math.round(vh * 0.78), 860);
14742
+
14743
+ el.style.width = `${width}px`;
14744
+ el.style.maxHeight = `${height}px`;
14745
+
14746
+ let left = rect.right + 16;
14747
+
14748
+ if (left + width > vw - 12) {
14749
+ left = Math.max(12, rect.left - width - 16);
14750
+ }
14751
+
14752
+ if (left + width > vw - 12) {
14753
+ left = Math.max(12, vw - width - 12);
14754
+ }
14755
+
14756
+ const top = Math.min(Math.max(12, rect.top - 60), Math.max(12, vh - height - 12));
14757
+
14758
+ el.style.left = `${Math.round(left)}px`;
14759
+ el.style.top = `${Math.round(top)}px`;
14760
+ };
14761
+
14762
+ const closePreview = () => {
14763
+ clearTimeout(previewHoverTimer);
14764
+ clearTimeout(previewCloseTimer);
14765
+
14766
+ if (previewEl) {
14767
+ previewEl.classList.remove('show');
14768
+ previewEl.querySelector('.dc-pv-body').textContent = '';
14769
+ }
14770
+
14771
+ previewAnchor?.classList.remove('dc-pv-anchor');
14772
+ previewAnchor = null;
14773
+ previewKey = '';
14774
+ };
14775
+
14776
+ const schedulePreviewClose = () => {
14777
+ clearTimeout(previewCloseTimer);
14778
+ previewCloseTimer = setTimeout(closePreview, 260);
14779
+ };
14780
+
14781
+ let previewHref = '';
14782
+
14783
+ const withPostReferrer = (href, fn) => {
14784
+ const back = location.href;
14785
+ let swapped = false;
14786
+
14787
+ try {
14788
+ const target = new URL(href, location.href);
14789
+
14790
+ if (target.origin === location.origin && target.href !== back) {
14791
+ history.replaceState(history.state, '', target.pathname + target.search);
14792
+ swapped = true;
14793
+ }
14794
+ } catch {
14795
+ }
14796
+
14797
+ try {
14798
+ return fn();
14799
+ } finally {
14800
+ if (swapped) {
14801
+ setTimeout(() => {
14802
+ try {
14803
+ history.replaceState(history.state, '', back);
14804
+ } catch {
14805
+ }
14806
+ }, 60);
14807
+ }
14808
+ }
14809
+ };
14810
+
14811
+ const cleanPreviewBody = source => withPostReferrer(previewHref, () => cleanPreviewBodyRaw(source));
14812
+
14813
+ const cleanPreviewBodyRaw = source => {
14814
+ const body = document.importNode(source, true);
14815
+ let hiddenMedia = 0;
14816
+
14817
+ body.querySelectorAll('script, style, link, iframe, object, embed, form, noscript, .dc-ui').forEach(node => {
14818
+ if (node.tagName === 'IFRAME') {
14819
+ const src = node.getAttribute('src') || '';
14820
+ const link = document.createElement('a');
14821
+
14822
+ link.href = src;
14823
+ link.textContent = '🎬 삽입된 동영상 열기';
14824
+ link.className = 'dc-pv-embed';
14825
+ node.replaceWith(link);
14826
+ } else {
14827
+ node.remove();
14828
+ }
14829
+ });
14830
+
14831
+ body.querySelectorAll('*').forEach(node => {
14832
+ [...node.attributes].forEach(attr => {
14833
+ if (/^on/i.test(attr.name) || (/^(href|src)$/i.test(attr.name) && /^\s*javascript:/i.test(attr.value))) {
14834
+ node.removeAttribute(attr.name);
14835
+ }
14836
+ });
14837
+ });
14838
+
14839
+ body.querySelectorAll('img, video').forEach(media => {
14840
+ const lazy = media.getAttribute('data-original') || media.getAttribute('data-src');
14841
+
14842
+ if (lazy && !media.getAttribute('src')) {
14843
+ media.setAttribute('src', lazy);
14844
+ }
14845
+
14846
+ const candidates = collectMediaCandidates(media);
14847
+
14848
+ if (isDcconElement(media, candidates)) {
14849
+ if (settings.blockDccon !== false) {
14850
+ media.remove();
14851
+ }
14852
+
14853
+ return;
14854
+ }
14855
+
14856
+ const blocked = findBlockedMediaKey(resolveMediaIds(media, candidates));
14857
+
14858
+ if (blocked) {
14859
+ const stub = document.createElement('div');
14860
+
14861
+ stub.className = 'dc-pv-blocked';
14862
+ stub.textContent = '🚫 차단한 이미지';
14863
+ (media.closest('a') || media).replaceWith(stub);
14864
+ hiddenMedia += 1;
14865
+ return;
14866
+ }
14867
+
14868
+ media.removeAttribute('loading');
14869
+ media.removeAttribute('width');
14870
+ media.removeAttribute('height');
14871
+
14872
+ if (media.tagName === 'VIDEO') {
14873
+ media.setAttribute('controls', '');
14874
+ media.setAttribute('preload', 'metadata');
14875
+ media.removeAttribute('autoplay');
14876
+ }
14877
+ });
14878
+
14879
+ body.querySelectorAll('a[href]').forEach(link => {
14880
+ link.target = '_blank';
14881
+ link.rel = 'noopener noreferrer';
14882
+ });
14883
+
14884
+ return { body, hiddenMedia };
14885
+ };
14886
+
14887
+ const readPreviewDoc = (html, key) => {
14888
+ const doc = new DOMParser().parseFromString(html, 'text/html');
14889
+ const source = doc.querySelector('.write_div') || doc.querySelector(POST_BODY_SELECTOR);
14890
+ const head = doc.querySelector('.gallview_head');
14891
+
14892
+ if (!source) {
14893
+ return { missing: true };
14894
+ }
14895
+
14896
+ const writer = head?.querySelector('.gall_writer');
14897
+ const text = normalizeWord(`${head?.querySelector('.title_subject')?.textContent || ''} ${source.textContent}`).slice(0, BODY_TEXT_LIMIT);
14898
+ const word = findWord(blockedKeywords, text);
14899
+
14900
+ if (key) {
14901
+ bodyScanCache[key] = { s: bodyScanSignature(), w: word, m: '', t: Date.now() };
14902
+ saveBodyScanSoon();
14903
+ }
14904
+
14905
+ return {
14906
+ title: cleanText(head?.querySelector('.title_subject')?.textContent || ''),
14907
+ head: cleanText(head?.querySelector('.title_headtext')?.textContent || '').replace(/^\[|\]$/g, ''),
14908
+ nick: writer?.getAttribute('data-nick') || cleanText(writer?.querySelector('.nickname')?.textContent || ''),
14909
+ ip: writer?.getAttribute('data-ip') || '',
14910
+ code: writer?.getAttribute('data-uid') || '',
14911
+ date: head?.querySelector('.gall_date')?.getAttribute('title') || cleanText(head?.querySelector('.gall_date')?.textContent || ''),
14912
+ stats: (cleanText(head?.querySelector('.fr')?.textContent || '').match(/(조회|추천|댓글)\s*[\d,]+/g) || []).join(' · '),
14913
+ source,
14914
+ word
14915
+ };
14916
+ };
14917
+
14918
+ const renderPreview = (data, href, key) => {
14919
+ const el = ensurePreviewEl();
14920
+
14921
+ previewHref = href;
14922
+ const titleEl = el.querySelector('.dc-pv-title');
14923
+ const metaEl = el.querySelector('.dc-pv-meta');
14924
+ const actions = el.querySelector('.dc-pv-actions');
14925
+ const bodyEl = el.querySelector('.dc-pv-body');
14926
+
14927
+ actions.textContent = '';
14928
+ bodyEl.textContent = '';
14929
+ bodyEl.scrollTop = 0;
14930
+ el.scrollTop = 0;
14931
+
14932
+ if (data.missing) {
14933
+ titleEl.textContent = '본문을 불러올 수 없습니다';
14934
+ metaEl.textContent = '삭제됐거나 성인 인증이 필요한 글입니다.';
14935
+ } else {
14936
+ titleEl.textContent = `${data.head ? `[${data.head}] ` : ''}${data.title || '(제목 없음)'}`;
14937
+ metaEl.textContent = [data.nick && `${data.nick}${data.ip ? `(${data.ip})` : ''}`, data.date, data.stats].filter(Boolean).join(' · ');
14938
+
14939
+ if (data.word) {
14940
+ const note = h('div', 'dc-pv-warn', `🚫 본문에 차단 단어 "${data.word}" 가 있어 가렸습니다${settings.prefetchBodies !== false ? ' · 목록에서도 가려집니다' : ''}`);
14941
+ const reveal = makeButton('그래도 보기', 'mini', () => {
14942
+ note.remove();
14943
+ bodyEl.appendChild(cleanPreviewBody(data.source).body);
14944
+ });
14945
+
14946
+ note.appendChild(reveal);
14947
+ bodyEl.appendChild(note);
14948
+ } else {
14949
+ const { body, hiddenMedia } = cleanPreviewBody(data.source);
14950
+
14951
+ if (hiddenMedia) {
14952
+ bodyEl.appendChild(h('div', 'dc-pv-note', `차단한 이미지 ${hiddenMedia}개를 뺐습니다`));
14953
+ }
14954
+
14955
+ bodyEl.appendChild(body);
14956
+ }
14957
+ }
14958
+
14959
+ const open = h('a', 'dc-btn mini', '글 열기');
14960
+
14961
+ open.href = href;
14962
+ actions.appendChild(open);
14963
+
14964
+ if (!data.missing && key) {
14965
+ const match = /[?&]id=([^&]+)&no=(\d+)/.exec(href) || [];
14966
+ const kind = /\/mgallery\//.test(href) ? 'M' : /\/mini\//.test(href) ? 'N' : /\/person\//.test(href) ? 'P' : 'G';
14967
+ const info = match[1] ? normalizeScrap({ k: kind, id: decodeURIComponent(match[1]), no: match[2], t: data.title, g: '', d: Date.now() }) : null;
14968
+
14969
+ if (info && isValidScrap(info)) {
14970
+ const scrapBtn = makeButton(isScrapped(info) ? '📌 스크랩됨' : '📌 스크랩', 'mini', () => {
14971
+ toggleScrap(info);
14972
+ scrapBtn.textContent = isScrapped(info) ? '📌 스크랩됨' : '📌 스크랩';
14973
+ });
14974
+
14975
+ actions.appendChild(scrapBtn);
14976
+ }
14977
+
14978
+ const userKey = data.code ? `id::${data.code}` : data.ip ? `ip::${data.ip}` : data.nick ? `nick::${data.nick}` : '';
14979
+
14980
+ if (userKey && !blockedUsers.includes(userKey)) {
14981
+ actions.appendChild(makeButton('🚫 작성자 차단', 'mini danger-ghost', () => {
14982
+ blockedUsers = sortUserKeys([...blockedUsers, userKey]);
14983
+ saveUsers();
14984
+ refreshAfterBlockChange();
14985
+ closePreview();
14986
+ offerUndo(`✓ ${describeHitKey(userKey).kind} ${describeHitKey(userKey).label} 차단`, () => {
14987
+ blockedUsers = blockedUsers.filter(item => item !== userKey);
14988
+ saveUsers();
14989
+ refreshAfterBlockChange();
14990
+ });
14991
+ }));
14992
+ }
14993
+ }
14994
+
14995
+ const close = makeButton('✕', 'mini dc-pv-close', closePreview);
14996
+
14997
+ close.setAttribute('aria-label', '미리보기 닫기');
14998
+ actions.appendChild(close);
14999
+ };
15000
+
15001
+ const showPreviewFor = async anchor => {
15002
+ if (!previewEnabled() || !anchor || !anchor.isConnected) {
15003
+ return;
15004
+ }
15005
+
15006
+ const row = anchor.closest('tr');
15007
+
15008
+ if (row && (row.classList.contains('dc-user-blocked') && !row.classList.contains('dc-user-revealed') || row.classList.contains('dc-head-hidden'))) {
15009
+ return;
15010
+ }
15011
+
15012
+ const href = anchor.href;
15013
+ const key = extractPostPath(href);
15014
+
15015
+ if (!key) {
15016
+ return;
15017
+ }
15018
+
15019
+ previewAnchor?.classList.remove('dc-pv-anchor');
15020
+ previewAnchor = anchor;
15021
+ previewKey = key;
15022
+ anchor.classList.add('dc-pv-anchor');
15023
+
15024
+ const el = ensurePreviewEl();
15025
+
15026
+ placePreview(anchor);
15027
+ el.classList.add('show');
15028
+
15029
+ const cached = previewCache.get(key);
15030
+
15031
+ if (cached && Date.now() - cached.at < PREVIEW_CACHE_MS) {
15032
+ renderPreview(cached.data, href, key);
15033
+ return;
15034
+ }
15035
+
15036
+ const paused = netPausedUntil();
15037
+
15038
+ el.querySelector('.dc-pv-title').textContent = cleanText(anchor.textContent);
15039
+ el.querySelector('.dc-pv-meta').textContent = '';
15040
+ el.querySelector('.dc-pv-actions').textContent = '';
15041
+ el.querySelector('.dc-pv-body').textContent = '';
15042
+
15043
+ if (paused) {
15044
+ el.querySelector('.dc-pv-body').appendChild(h('div', 'dc-pv-note', `⏸ 디시 요청 제한 중이라 ${Math.max(1, Math.ceil((paused - Date.now()) / 60000))}분 뒤에 미리볼 수 있습니다`));
15045
+ return;
15046
+ }
15047
+
15048
+ el.querySelector('.dc-pv-body').appendChild(h('div', 'dc-pv-loading', '불러오는 중…'));
15049
+
15050
+ try {
15051
+ const html = await siteFetchText(href, { credentials: 'same-origin', cache: 'no-cache' });
15052
+ const data = readPreviewDoc(html, key);
15053
+
15054
+ previewCache.set(key, { at: Date.now(), data });
15055
+
15056
+ while (previewCache.size > PREVIEW_CACHE_MAX) {
15057
+ previewCache.delete(previewCache.keys().next().value);
15058
+ }
15059
+
15060
+ if (previewKey === key) {
15061
+ renderPreview(data, href, key);
15062
+
15063
+ if (data.word && settings.prefetchBodies !== false) {
15064
+ refreshAfterBlockChange();
15065
+ }
15066
+ }
15067
+ } catch (error) {
15068
+ if (previewKey !== key) {
15069
+ return;
15070
+ }
15071
+
15072
+ const body = el.querySelector('.dc-pv-body');
15073
+
15074
+ body.textContent = '';
15075
+ body.appendChild(h('div', 'dc-pv-note', error instanceof NetPausedError
15076
+ ? '⏸ 디시 요청 제한 중이라 잠시 뒤에 미리볼 수 있습니다'
15077
+ : '불러오지 못했습니다. 잠시 뒤 다시 올려 보세요.'));
15078
+ }
15079
+ };
15080
+
15081
+ const initHoverPreview = () => {
15082
+ if (!IS_PC_SITE) {
15083
+ return;
15084
+ }
15085
+
15086
+ document.addEventListener('mouseover', event => {
15087
+ if (!previewEnabled() || (event.pointerType && event.pointerType !== 'mouse')) {
15088
+ return;
15089
+ }
15090
+
15091
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15092
+
15093
+ if (!anchor || anchor.closest('.dc-ui')) {
15094
+ return;
15095
+ }
15096
+
15097
+ clearTimeout(previewCloseTimer);
15098
+
15099
+ if (anchor === previewAnchor) {
15100
+ return;
15101
+ }
15102
+
15103
+ clearTimeout(previewHoverTimer);
15104
+ previewHoverTimer = setTimeout(() => showPreviewFor(anchor), PREVIEW_DELAY);
15105
+ });
15106
+
15107
+ document.addEventListener('mouseout', event => {
15108
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15109
+
15110
+ if (!anchor || (event.relatedTarget instanceof Node && anchor.contains(event.relatedTarget))) {
15111
+ return;
15112
+ }
15113
+
15114
+ clearTimeout(previewHoverTimer);
15115
+
15116
+ if (previewEl && previewEl.classList.contains('show')) {
15117
+ schedulePreviewClose();
15118
+ }
15119
+ });
15120
+
15121
+ document.addEventListener('mousedown', event => {
15122
+ if (previewEl && previewEl.classList.contains('show') && !(event.target instanceof Node && previewEl.contains(event.target))) {
15123
+ closePreview();
15124
+ }
15125
+ }, true);
15126
+
15127
+ window.addEventListener('scroll', () => {
15128
+ if (previewEl && previewEl.classList.contains('show') && previewAnchor && !previewEl.matches(':hover')) {
15129
+ closePreview();
15130
+ }
15131
+ }, { passive: true });
15132
+
15133
+ document.addEventListener('keydown', event => {
15134
+ if (event.key === 'Escape' && previewEl && previewEl.classList.contains('show')) {
15135
+ closePreview();
15136
+ }
15137
+ });
15138
+ };
15139
+
15140
+ const togglePreviewForCursor = () => {
15141
+ if (!previewEnabled()) {
15142
+ return false;
15143
+ }
15144
+
15145
+ if (previewEl && previewEl.classList.contains('show')) {
15146
+ closePreview();
15147
+ return true;
15148
+ }
15149
+
15150
+ const anchor = keyCursor && keyCursor.isConnected ? keyCursor.querySelector(PREVIEW_TITLE_SELECTOR.replace('table.gall_list ', '')) : null;
15151
+
15152
+ if (!anchor) {
15153
+ return false;
15154
+ }
15155
+
15156
+ showPreviewFor(anchor);
15157
+ return true;
15158
+ };
13876
15159
  // =========================================================
13877
15160
  // 14. 플로팅 버튼
13878
15161
  // =========================================================
@@ -14860,6 +16143,8 @@
14860
16143
  initSync();
14861
16144
  initRecent();
14862
16145
  initSiteAlarms();
16146
+ initKeyboardShortcuts();
16147
+ initHoverPreview();
14863
16148
  restoreListSnapshot();
14864
16149
  processElements();
14865
16150