@kangsicj/dcinsideenhancer 1.1.1 → 1.2.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.
@@ -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.1
5
+ // @version 1.2.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.1.1';
78
+ const SCRIPT_VERSION = '1.2.1';
79
79
  const PROJECT_URL = 'https://github.com/kangsicj/DCInsideEnhancer';
80
80
  const DONATE_URL = 'https://fairy.hada.io/@jinny#support';
81
81
 
@@ -108,6 +108,7 @@
108
108
  recent: 'dc_recent_galls',
109
109
  recentNative: 'dc_recent_native',
110
110
  searchHistory: 'dc_search_history',
111
+ blockHits: 'dc_block_hits',
111
112
  alarmSeen: 'dc_alarm_marks',
112
113
  seenVersion: 'dc_seen_version'
113
114
  };
@@ -224,6 +225,8 @@
224
225
  alarmBubble: true,
225
226
  alarmKeepPolling: true,
226
227
  keyboardShortcuts: true,
228
+ hoverPreview: true,
229
+ alarmSystemNotify: false,
227
230
  pcFitWidth: true,
228
231
  autoHideScrollbar: true,
229
232
  scrapIntercept: true,
@@ -409,67 +412,76 @@
409
412
  html.dc-home-media-off .main-wrapping section.grid.media-group { display: none !important; }
410
413
  html.dc-footer-info-off footer.footer > .ft-btm { display: none !important; }
411
414
 
412
- html.dc-pc-fit { --dc-fit-gap: clamp(12px, 2vw, 32px); }
413
- html.dc-pc-fit body,
414
- html.dc-pc-fit .dcwrap { min-width: 0 !important; }
415
- html.dc-pc-fit .dcwrap .dchead,
416
- html.dc-pc-fit .dcwrap .gnb,
417
- html.dc-pc-fit .dcwrap .gnb_depth2_list,
418
- html.dc-pc-fit .dcwrap .info_policy,
419
- html.dc-pc-fit .dcwrap .copyright,
420
- html.dc-pc-fit .dcwrap .visit_bookmark {
421
- width: auto !important;
422
- max-width: none !important;
423
- margin-left: var(--dc-fit-gap) !important;
424
- margin-right: var(--dc-fit-gap) !important;
425
- }
426
-
427
- html.dc-pc-fit .dcwrap .gnb_bar { min-width: 0 !important; width: auto !important; margin: 0 !important; }
428
- html.dc-pc-fit .dcwrap .gnb_list,
429
- html.dc-pc-fit .dcwrap .dc_all { width: auto !important; }
430
- html.dc-pc-fit .dcwrap .wrap_inner { width: auto !important; }
431
- html.dc-pc-fit .dcwrap #container {
432
- width: auto !important;
433
- margin-left: var(--dc-fit-gap) !important;
434
- margin-right: var(--dc-fit-gap) !important;
435
- }
436
- html.dc-pc-fit .dcwrap #container:has(> .left_content),
437
- html.dc-pc-fit .dcwrap #container > .main_content:has(> .left_content) {
438
- display: flex;
439
- flex-wrap: wrap;
440
- align-items: flex-start;
441
- gap: 20px;
442
- width: auto !important;
443
- }
444
- html.dc-pc-fit .dcwrap #container:has(> .left_content)::before,
445
- html.dc-pc-fit .dcwrap #container:has(> .left_content)::after,
446
- html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::before,
447
- html.dc-pc-fit .dcwrap .main_content:has(> .left_content)::after { display: none !important; }
448
- html.dc-pc-fit .dcwrap #container:has(> .left_content) > :not(.left_content):not(.right_content),
449
- html.dc-pc-fit .dcwrap .main_content:has(> .left_content) > :not(.left_content):not(.right_content) {
450
- flex: 0 0 100%;
451
- float: none !important;
452
- }
453
- html.dc-pc-fit .dcwrap :is(#container, .main_content) > .left_content {
454
- float: none !important;
455
- flex: 1 1 0;
456
- width: auto !important;
457
- min-width: 0;
458
- }
459
- html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content {
460
- float: none !important;
461
- flex: 0 0 300px;
462
- margin-left: 0 !important;
463
- }
464
- html.dc-pc-fit .dcwrap .issuebox,
465
- html.dc-pc-fit .dcwrap .newvisit_history,
466
- html.dc-pc-fit .dcwrap .trendalign { width: auto !important; }
467
- html.dc-pc-fit .dcwrap .list_array_option { width: 100% !important; }
468
- html.dc-pc-fit .dcwrap .stickyunit { display: none !important; }
469
- html.dc-pc-fit .dcwrap .darkmodebox .pop_tipbox { left: auto !important; right: 0 !important; }
470
-
471
- @media (max-width: 1240px) {
472
- html.dc-pc-fit .dcwrap :is(#container, .main_content) > .right_content { display: none !important; }
415
+ @media (max-width: 1519.98px) {
416
+ html.dc-pc-fit { --dc-fit-gap: clamp(10px, 1.6vw, 24px); }
417
+ html.dc-pc-fit body,
418
+ html.dc-pc-fit .dcwrap { min-width: 0 !important; }
419
+ html.dc-pc-fit body { overflow-x: hidden; }
420
+ html.dc-pc-fit .dcwrap .dchead,
421
+ html.dc-pc-fit .dcwrap .gnb,
422
+ html.dc-pc-fit .dcwrap .gnb_depth2_list,
423
+ html.dc-pc-fit .dcwrap .info_policy,
424
+ html.dc-pc-fit .dcwrap .copyright,
425
+ html.dc-pc-fit .dcwrap .visit_bookmark {
426
+ width: auto !important;
427
+ max-width: none !important;
428
+ margin-left: var(--dc-fit-gap) !important;
429
+ margin-right: var(--dc-fit-gap) !important;
430
+ }
431
+
432
+ html.dc-pc-fit .dcwrap .gnb_bar { min-width: 0 !important; width: auto !important; margin: 0 !important; }
433
+ html.dc-pc-fit .dcwrap .gnb_list,
434
+ html.dc-pc-fit .dcwrap .dc_all,
435
+ html.dc-pc-fit .dcwrap .wrap_inner { width: auto !important; }
436
+ html.dc-pc-fit .dcwrap #container {
437
+ width: auto !important;
438
+ margin-left: var(--dc-fit-gap) !important;
439
+ margin-right: var(--dc-fit-gap) !important;
440
+ }
441
+
442
+ html.dc-pc-fit .dcwrap :is(#container, .main_content, #bottom_listwrap) > .right_content { display: none !important; }
443
+ html.dc-pc-fit .dcwrap :is(#container, .main_content, #bottom_listwrap) > .left_content {
444
+ float: none !important;
445
+ width: auto !important;
446
+ max-width: none !important;
447
+ min-width: 0;
448
+ }
449
+ html.dc-pc-fit .dcwrap .main_content { width: auto !important; }
450
+
451
+ html.dc-pc-fit .dcwrap :is(.view_content_wrap, .gallview_head, .writing_view_box, .view_comment, .comment_wrap,
452
+ .comment_box, .cmt_write_box, #bottom_listwrap, .listwrap, .gall_listwrap, .page_head, .issuebox,
453
+ .newvisit_history, .trendalign, .bottom_paging_wrap, .bottom_paging_box, .bottom_search, .btn_recommend_box) {
454
+ width: auto !important;
455
+ max-width: 100% !important;
456
+ box-sizing: border-box;
457
+ }
458
+ html.dc-pc-fit .dcwrap .write_div { max-width: 100% !important; }
459
+ html.dc-pc-fit .dcwrap .write_div img,
460
+ html.dc-pc-fit .dcwrap .write_div video,
461
+ html.dc-pc-fit .dcwrap .write_div iframe { max-width: 100% !important; }
462
+ html.dc-pc-fit .dcwrap .list_array_option { width: 100% !important; }
463
+ html.dc-pc-fit .dcwrap .stickyunit { display: none !important; }
464
+ html.dc-pc-fit .dcwrap .darkmodebox .pop_tipbox { left: auto !important; right: 0 !important; }
465
+
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; }
474
+
475
+ html.dc-pc-fit .dcwrap .cmt_write_box > .fl { width: 142px; }
476
+ html.dc-pc-fit .dcwrap .cmt_write_box > .cmt_txt_cont {
477
+ float: right !important;
478
+ width: calc(100% - 158px) !important;
479
+ }
480
+ html.dc-pc-fit .dcwrap .cmt_write_box > .cmt_txt_cont .cmt_write,
481
+ html.dc-pc-fit .dcwrap .cmt_write_box > .cmt_txt_cont textarea,
482
+ html.dc-pc-fit .dcwrap .cmt_write_box > .cmt_txt_cont .cmt_cont_bottm { width: 100% !important; box-sizing: border-box; }
483
+
484
+ html.dc-pc-fit .dcwrap .cmt_write_box:not(:has(> .fl)) > .cmt_txt_cont { width: 100% !important; }
473
485
  }
474
486
 
475
487
  html.dc-footer-info-off .dcwrap footer.dcfoot,
@@ -1851,6 +1863,158 @@
1851
1863
 
1852
1864
  #dc-custom-modal .dc-row.dc-row-new .dc-row-title { font-weight: 800; }
1853
1865
 
1866
+ #dc-preview {
1867
+ position: fixed;
1868
+ top: 50%;
1869
+ left: 50%;
1870
+ z-index: 99997;
1871
+ display: flex;
1872
+ flex-direction: column;
1873
+ box-sizing: border-box;
1874
+ width: clamp(520px, 52vw, 980px);
1875
+ height: clamp(420px, 84vh, 1100px);
1876
+ max-width: calc(100vw - 32px);
1877
+ max-height: calc(100vh - 32px);
1878
+ border: 1px solid var(--dc-border);
1879
+ border-radius: 16px;
1880
+ overflow: hidden;
1881
+ background: var(--dc-surface);
1882
+ color: var(--dc-text);
1883
+ box-shadow: 0 18px 60px rgba(0,0,0,.28), var(--dc-shadow);
1884
+ font-size: 14px;
1885
+ line-height: 1.6;
1886
+ opacity: 0;
1887
+ visibility: hidden;
1888
+ pointer-events: none;
1889
+ transform: translate(-50%, -50%) scale(.97);
1890
+ transition: opacity .16s ease, transform .16s ease, visibility 0s linear .16s;
1891
+ }
1892
+
1893
+ #dc-preview.show {
1894
+ opacity: 1;
1895
+ visibility: visible;
1896
+ pointer-events: auto;
1897
+ transform: translate(-50%, -50%);
1898
+ transition: opacity .16s ease, transform .16s ease, visibility 0s;
1899
+ }
1900
+
1901
+ #dc-preview .dc-pv-head {
1902
+ flex: 0 0 auto;
1903
+ padding: 14px 18px 10px;
1904
+ border-bottom: 1px solid var(--dc-border);
1905
+ background: var(--dc-surface);
1906
+ }
1907
+
1908
+ #dc-preview .dc-pv-title {
1909
+ display: -webkit-box;
1910
+ overflow: hidden;
1911
+ -webkit-line-clamp: 2;
1912
+ -webkit-box-orient: vertical;
1913
+ font-size: 16px;
1914
+ font-weight: 800;
1915
+ line-height: 1.4;
1916
+ word-break: break-all;
1917
+ }
1918
+
1919
+ #dc-preview .dc-pv-meta {
1920
+ margin-top: 3px;
1921
+ color: var(--dc-text-dim);
1922
+ font-size: 12px;
1923
+ }
1924
+
1925
+ #dc-preview .dc-pv-actions {
1926
+ display: flex;
1927
+ flex-wrap: wrap;
1928
+ align-items: center;
1929
+ gap: 6px;
1930
+ margin-top: 9px;
1931
+ }
1932
+
1933
+ #dc-preview .dc-pv-hint { margin-left: auto; color: var(--dc-text-dim); font-size: 11.5px; }
1934
+ #dc-preview .dc-pv-notes:empty { display: none; }
1935
+ #dc-preview .dc-pv-notes { margin-top: 6px; }
1936
+
1937
+ #dc-preview .dc-btn {
1938
+ display: inline-flex;
1939
+ align-items: center;
1940
+ justify-content: center;
1941
+ box-sizing: border-box;
1942
+ height: 30px;
1943
+ padding: 0 12px;
1944
+ border: 1px solid var(--dc-border);
1945
+ border-radius: 999px;
1946
+ background: var(--dc-surface-2);
1947
+ color: var(--dc-text);
1948
+ font-size: 12.5px;
1949
+ font-weight: 700;
1950
+ line-height: 1;
1951
+ text-decoration: none;
1952
+ cursor: pointer;
1953
+ }
1954
+
1955
+ #dc-preview .dc-btn:hover { border-color: var(--dc-accent-ink); color: var(--dc-accent-ink); }
1956
+ #dc-preview .dc-btn.primary { border-color: var(--dc-accent); background: var(--dc-accent); color: var(--dc-on-accent); }
1957
+ #dc-preview .dc-btn.primary:hover { color: var(--dc-on-accent); filter: brightness(1.08); }
1958
+ #dc-preview .dc-btn.danger-ghost { color: var(--dc-danger-ink); }
1959
+ #dc-preview .dc-btn.dc-pv-close { width: 30px; padding: 0; font-size: 14px; }
1960
+
1961
+ #dc-preview .dc-pv-body {
1962
+ position: relative;
1963
+ display: flex;
1964
+ flex-direction: column;
1965
+ flex: 1 1 auto;
1966
+ min-height: 0;
1967
+ background: var(--dc-surface);
1968
+ }
1969
+
1970
+ #dc-preview .dc-pv-frame {
1971
+ flex: 1 1 auto;
1972
+ width: 100%;
1973
+ min-height: 0;
1974
+ border: 0;
1975
+ background: var(--dc-surface);
1976
+ }
1977
+
1978
+ #dc-preview .dc-pv-loading,
1979
+ #dc-preview .dc-pv-note {
1980
+ padding: 12px 18px;
1981
+ color: var(--dc-text-dim);
1982
+ font-size: 13px;
1983
+ }
1984
+
1985
+ #dc-preview .dc-pv-notes .dc-pv-note { padding: 0; font-size: 12px; }
1986
+
1987
+ #dc-preview .dc-pv-loading::before {
1988
+ content: '';
1989
+ display: inline-block;
1990
+ width: 12px;
1991
+ height: 12px;
1992
+ margin-right: 8px;
1993
+ border: 2px solid var(--dc-border);
1994
+ border-top-color: var(--dc-accent-ink);
1995
+ border-radius: 50%;
1996
+ vertical-align: -1px;
1997
+ animation: dc-spin .7s linear infinite;
1998
+ }
1999
+
2000
+ @keyframes dc-spin { to { transform: rotate(360deg); } }
2001
+
2002
+ #dc-preview .dc-pv-warn {
2003
+ display: flex;
2004
+ flex-wrap: wrap;
2005
+ align-items: center;
2006
+ gap: 8px;
2007
+ margin: 14px 18px;
2008
+ padding: 10px 12px;
2009
+ border-radius: 10px;
2010
+ background: var(--dc-danger-soft);
2011
+ color: var(--dc-danger-ink);
2012
+ font-size: 13px;
2013
+ font-weight: 700;
2014
+ }
2015
+
2016
+ .dc-pv-anchor { text-decoration: underline !important; text-decoration-color: var(--dc-accent-ink) !important; }
2017
+
1854
2018
  .dc-key-cursor { outline: 2px solid var(--dc-accent-ink) !important; outline-offset: -2px; background: var(--dc-accent-soft) !important; }
1855
2019
 
1856
2020
  #dc-custom-modal .dc-about {
@@ -6462,7 +6626,97 @@
6462
6626
 
6463
6627
  const currentPostKey = () => extractPostPath(location.href) || location.pathname;
6464
6628
 
6465
- const computeBlockReason = el => {
6629
+ const HIT_DAY_MS = 86400000;
6630
+ const hitToday = () => Math.floor(Date.now() / HIT_DAY_MS);
6631
+
6632
+ const readBlockHits = () => {
6633
+ const stored = readObject(STORAGE.blockHits);
6634
+
6635
+ return {
6636
+ since: Number(stored.since) || hitToday(),
6637
+ k: stored.k && typeof stored.k === 'object' && !Array.isArray(stored.k) ? { ...stored.k } : {}
6638
+ };
6639
+ };
6640
+
6641
+ let blockHits = readBlockHits();
6642
+ let blockHitsDirty = !readObject(STORAGE.blockHits).since;
6643
+ let blockHitsTimer = null;
6644
+
6645
+ const blockHitKeys = () => [
6646
+ ...blockedUsers,
6647
+ ...blockedTitles.map(word => `t:${word}`),
6648
+ ...blockedKeywords.map(word => `w:${word}`)
6649
+ ];
6650
+
6651
+ const flushBlockHits = () => {
6652
+ clearTimeout(blockHitsTimer);
6653
+ blockHitsTimer = null;
6654
+
6655
+ if (!blockHitsDirty) {
6656
+ return;
6657
+ }
6658
+
6659
+ blockHitsDirty = false;
6660
+
6661
+ const today = hitToday();
6662
+ const next = {};
6663
+
6664
+ blockHitKeys().forEach(key => {
6665
+ next[key] = Number(blockHits.k[key]) || today;
6666
+ });
6667
+
6668
+ blockHits = { since: blockHits.since, k: next };
6669
+ writeJson(STORAGE.blockHits, blockHits);
6670
+ };
6671
+
6672
+ const scheduleBlockHitsFlush = () => {
6673
+ blockHitsDirty = true;
6674
+
6675
+ if (!blockHitsTimer) {
6676
+ blockHitsTimer = setTimeout(flushBlockHits, 20000);
6677
+ }
6678
+ };
6679
+
6680
+ const noteBlockHit = reason => {
6681
+ if (!reason) {
6682
+ return reason;
6683
+ }
6684
+
6685
+ const keys = reason.type === 'user'
6686
+ ? reason.userKeys || []
6687
+ : reason.type === 'title'
6688
+ ? [`t:${reason.word}`]
6689
+ : reason.type === 'keyword'
6690
+ ? [blockedTitles.includes(reason.word) && !blockedKeywords.includes(reason.word) ? `t:${reason.word}` : `w:${reason.word}`]
6691
+ : [];
6692
+ const today = hitToday();
6693
+
6694
+ keys.forEach(key => {
6695
+ if (blockHits.k[key] !== today) {
6696
+ blockHits.k[key] = today;
6697
+ scheduleBlockHitsFlush();
6698
+ }
6699
+ });
6700
+
6701
+ return reason;
6702
+ };
6703
+
6704
+ window.addEventListener('pagehide', flushBlockHits);
6705
+ document.addEventListener('visibilitychange', () => {
6706
+ if (document.visibilityState === 'hidden') {
6707
+ flushBlockHits();
6708
+ }
6709
+ });
6710
+
6711
+ storageWriteListeners.push(key => {
6712
+ if (key === STORAGE.users || key === STORAGE.keywords || key === STORAGE.titles) {
6713
+ scheduleBlockHitsFlush();
6714
+ }
6715
+ });
6716
+
6717
+ const computeBlockReason = el => noteBlockHit(computeBlockReasonRaw(el));
6718
+
6719
+ const computeBlockReasonRaw = el => {
6466
6720
  const hits = buildBlockKeys({
6467
6721
  nickname: el.dataset.dcNick || '',
6468
6722
  ip: el.dataset.dcIp || '',
@@ -9987,6 +10241,7 @@
9987
10241
  });
9988
10242
 
9989
10243
  area.appendChild(seg);
10244
+ renderCleanupHelper(area);
9990
10245
  (renderBlockSections[blockSection] || renderBlockSections.keywords)(area);
9991
10246
  };
9992
10247
 
@@ -10173,6 +10428,15 @@
10173
10428
  off: '사이트 기본 한 줄 알림을 그대로 씁니다.',
10174
10429
  refresh: 'root'
10175
10430
  },
10431
+ {
10432
+ group: 'alarm',
10433
+ key: 'alarmSystemNotify',
10434
+ parent: 'alarmBubble',
10435
+ name: '다른 탭을 볼 때 기기 알림으로도 (PC)',
10436
+ on: '이 탭을 보고 있지 않을 때 디시 알림이 오면 윈도우·맥 알림으로 띄웁니다. 누르면 이 탭으로 돌아와 해당 글로 갑니다. 처음 켤 때 브라우저가 권한을 묻습니다.',
10437
+ off: '이 탭을 볼 때 말풍선으로만 알립니다.',
10438
+ refresh: 'root'
10439
+ },
10176
10440
  {
10177
10441
  group: 'alarm',
10178
10442
  key: 'alarmKeepPolling',
@@ -10206,6 +10470,14 @@
10206
10470
  off: '단축키를 쓰지 않습니다.',
10207
10471
  refresh: 'none'
10208
10472
  },
10473
+ {
10474
+ group: 'list',
10475
+ key: 'hoverPreview',
10476
+ name: '제목에 마우스를 올리면 글 미리보기 (PC)',
10477
+ on: '목록 제목 위에 잠깐 머무르면 글 전체를 옆 창에 보여 줍니다. 창 안에서 스크롤해 읽고, 스크랩·작성자 차단도 바로 할 수 있습니다. 차단한 이미지·단어는 미리보기에서도 가립니다. 키보드로 고른 글은 P 로 엽니다.',
10478
+ off: '제목을 눌러야 글을 봅니다.',
10479
+ refresh: 'root'
10480
+ },
10209
10481
  {
10210
10482
  group: 'list',
10211
10483
  key: 'autoFillList',
@@ -10403,6 +10675,20 @@
10403
10675
  paintAlarmBadge();
10404
10676
  }
10405
10677
 
10678
+ if (item.key === 'hoverPreview' && settings.hoverPreview === false) {
10679
+ closePreview();
10680
+ }
10681
+
10682
+ if (item.key === 'alarmSystemNotify' && settings.alarmSystemNotify === true) {
10683
+ requestSystemNotify().then(granted => {
10684
+ if (!granted) {
10685
+ settings.alarmSystemNotify = false;
10686
+ saveSettings();
10687
+ rerenderModal();
10688
+ }
10689
+ });
10690
+ }
10691
+
10406
10692
  break;
10407
10693
 
10408
10694
  case 'log':
@@ -11626,7 +11912,8 @@
11626
11912
  scraps: scraps.map(item => ({ ...item })),
11627
11913
  settings: syncedSettings(),
11628
11914
  recent: collectRecentForSync(),
11629
- search: collectSearchForSync()
11915
+ search: collectSearchForSync(),
11916
+ hits: collectHitsForSync()
11630
11917
  });
11631
11918
 
11632
11919
  const skippedFields = skip => SYNC_PARTS.filter(part => skip.includes(part.key)).flatMap(part => part.fields);
@@ -11734,7 +12021,8 @@
11734
12021
  scraps: mergeScraps(local.scraps, base && base.scraps, remote && remote.scraps),
11735
12022
  settings: mergeSettings(local.settings, base && base.settings, remote && remote.settings),
11736
12023
  recent: mergeRecent(local.recent, base && base.recent, remote && remote.recent),
11737
- search: mergeSearchHistory(local.search, base && base.search, remote && remote.search)
12024
+ search: mergeSearchHistory(local.search, base && base.search, remote && remote.search),
12025
+ hits: mergeBlockHits(local.hits, remote && remote.hits)
11738
12026
  };
11739
12027
 
11740
12028
  skippedFields(skip).forEach(field => {
@@ -11789,6 +12077,7 @@
11789
12077
 
11790
12078
  delete data.recent;
11791
12079
  delete data.search;
12080
+ delete data.hits;
11792
12081
 
11793
12082
  return JSON.stringify(data);
11794
12083
  };
@@ -11796,6 +12085,7 @@
11796
12085
  const applySyncData = data => {
11797
12086
  const recentChanged = data && 'recent' in data ? applyRecentFromSync(data.recent) : false;
11798
12087
  const searchChanged = data && 'search' in data ? applySearchFromSync(data.search) : false;
12088
+ const hitsChanged = data && 'hits' in data ? applyHitsFromSync(data.hits) : false;
11799
12089
  const clean = sanitizeBackup({ app: BACKUP_APP, ...data });
11800
12090
  const before = blockSyncData();
11801
12091
 
@@ -11850,7 +12140,7 @@
11850
12140
  refreshAfterBlockChange();
11851
12141
  }
11852
12142
 
11853
- return changed || recentChanged || searchChanged;
12143
+ return changed || recentChanged || searchChanged || hitsChanged;
11854
12144
  };
11855
12145
 
11856
12146
  const saveSyncUndo = (before, incoming, summary, from) => {
@@ -13751,7 +14041,9 @@
13751
14041
  let alarmWatchTimer = null;
13752
14042
 
13753
14043
  const checkSiteAlarms = () => {
13754
- if (settings.alarmBubble === false || document.visibilityState === 'hidden') {
14044
+ const away = document.visibilityState === 'hidden' || (typeof document.hasFocus === 'function' && !document.hasFocus());
14045
+
14046
+ if (settings.alarmBubble === false || (document.visibilityState === 'hidden' && !canSystemNotify())) {
13755
14047
  return;
13756
14048
  }
13757
14049
 
@@ -13779,7 +14071,11 @@
13779
14071
  writeAlarmMarks({ shown: latest });
13780
14072
  }
13781
14073
 
13782
- if (fresh.length) {
14074
+ if (fresh.length && away && canSystemNotify()) {
14075
+ showSystemAlarm(fresh[0], fresh.length - 1);
14076
+ }
14077
+
14078
+ if (fresh.length && document.visibilityState !== 'hidden') {
13783
14079
  showAlarmBubble(fresh[0], fresh.length > ALARM_BUBBLE_MAX ? fresh.length - 1 : 0);
13784
14080
 
13785
14081
  if (fresh.length > 1 && fresh.length <= ALARM_BUBBLE_MAX) {
@@ -13990,7 +14286,7 @@
13990
14286
 
13991
14287
  // =========================================================
13992
14288
 
13993
- const KEY_HELP = 'J/K 다음·이전 글 · Enter 열기 · S 스크랩 · / 검색 · B 차단 관리';
14289
+ const KEY_HELP = 'J/K 다음·이전 글 · Enter 열기 · P 미리보기 · S 스크랩 · / 검색 · B 차단 관리';
13994
14290
 
13995
14291
  const typingTarget = el =>
13996
14292
  !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || !!el.closest?.('[contenteditable="true"]'));
@@ -14008,6 +14304,10 @@
14008
14304
  let keyCursor = null;
14009
14305
 
14010
14306
  const moveKeyCursor = step => {
14307
+ if (previewOpen()) {
14308
+ closePreview();
14309
+ }
14310
+
14011
14311
  const rows = visibleListRows();
14012
14312
 
14013
14313
  if (!rows.length) {
@@ -14091,6 +14391,8 @@
14091
14391
  } else if (key === 'b' || key === 'B') {
14092
14392
  openBlockModal(document.getElementById('dc-custom-fab')?.dataset.dcAlarm ? 'recent' : 'overview');
14093
14393
  handled = true;
14394
+ } else if ((key === 'p' || key === 'P') && togglePreviewForCursor()) {
14395
+ handled = true;
14094
14396
  } else if (key === '?') {
14095
14397
  showToast(`⌨️ ${KEY_HELP}`, { duration: 6000 });
14096
14398
  handled = true;
@@ -14159,6 +14461,831 @@
14159
14461
 
14160
14462
  return true;
14161
14463
  };
14464
+
14465
+ // =========================================================
14466
+ // 13-4. 차단 목록 정리 도우미 · 기기 알림 · PC 글 미리보기
14467
+ // =========================================================
14468
+
14469
+ const collectHitsForSync = () => {
14470
+ flushBlockHits();
14471
+
14472
+ return { since: blockHits.since, k: { ...blockHits.k } };
14473
+ };
14474
+
14475
+ const mergeBlockHits = (local, remote) => {
14476
+ if (!remote || typeof remote !== 'object' || !remote.k) {
14477
+ return local;
14478
+ }
14479
+
14480
+ const k = { ...(remote.k || {}) };
14481
+
14482
+ Object.entries((local && local.k) || {}).forEach(([key, day]) => {
14483
+ k[key] = Math.max(Number(k[key]) || 0, Number(day) || 0);
14484
+ });
14485
+
14486
+ return { since: Math.min(Number(local && local.since) || hitToday(), Number(remote.since) || hitToday()), k };
14487
+ };
14488
+
14489
+ const applyHitsFromSync = data => {
14490
+ if (!data || typeof data !== 'object' || !data.k) {
14491
+ return false;
14492
+ }
14493
+
14494
+ const merged = mergeBlockHits(blockHits, data);
14495
+
14496
+ if (JSON.stringify(merged) === JSON.stringify(blockHits)) {
14497
+ return false;
14498
+ }
14499
+
14500
+ blockHits = merged;
14501
+ blockHitsDirty = true;
14502
+ flushBlockHits();
14503
+
14504
+ return true;
14505
+ };
14506
+
14507
+ // =========================================================
14508
+
14509
+ // =========================================================
14510
+
14511
+ const CLEANUP_IDLE_DAYS = 60;
14512
+
14513
+ const cleanupSuggestions = () => {
14514
+ flushBlockHits();
14515
+
14516
+ const today = hitToday();
14517
+ const tracked = today - blockHits.since;
14518
+ const idle = [];
14519
+
14520
+ if (tracked >= CLEANUP_IDLE_DAYS) {
14521
+ blockHitKeys().forEach(key => {
14522
+ const day = Number(blockHits.k[key]) || today;
14523
+
14524
+ if (today - day >= CLEANUP_IDLE_DAYS) {
14525
+ idle.push({ key, days: today - day });
14526
+ }
14527
+ });
14528
+ }
14529
+
14530
+ const redundant = [];
14531
+
14532
+ blockedKeywords.forEach(word => {
14533
+ const cover = blockedKeywords.find(other => other !== word && other.length < word.length && word.includes(other));
14534
+
14535
+ if (cover) {
14536
+ redundant.push({ key: `w:${word}`, cover });
14537
+ }
14538
+ });
14539
+
14540
+ blockedTitles.forEach(word => {
14541
+ const cover = [...blockedKeywords, ...blockedTitles].find(other => other !== word && other.length < word.length && word.includes(other));
14542
+
14543
+ if (cover) {
14544
+ redundant.push({ key: `t:${word}`, cover });
14545
+ }
14546
+ });
14547
+
14548
+ return { idle: idle.sort((a, b) => b.days - a.days), redundant, tracked };
14549
+ };
14550
+
14551
+ const describeHitKey = key => {
14552
+ if (key.startsWith('w:')) {
14553
+ return { label: key.slice(2), kind: '단어' };
14554
+ }
14555
+
14556
+ if (key.startsWith('t:')) {
14557
+ return { label: key.slice(2), kind: '제목 단어' };
14558
+ }
14559
+
14560
+ const parsed = parseBlockKey(key);
14561
+
14562
+ return { label: parsed.value, kind: BLOCK_TYPE_LABELS[parsed.type] || '유저' };
14563
+ };
14564
+
14565
+ const removeBlockKeys = keys => {
14566
+ const before = { users: [...blockedUsers], keywords: [...blockedKeywords], titles: [...blockedTitles] };
14567
+ const drop = new Set(keys);
14568
+
14569
+ blockedUsers = blockedUsers.filter(key => !drop.has(key));
14570
+ blockedKeywords = blockedKeywords.filter(word => !drop.has(`w:${word}`));
14571
+ blockedTitles = blockedTitles.filter(word => !drop.has(`t:${word}`));
14572
+ saveUsers();
14573
+ saveKeywords();
14574
+ saveTitles();
14575
+ refreshAfterBlockChange();
14576
+
14577
+ showToast(`🧹 ${keys.length}개 정리함`, {
14578
+ actionLabel: '되돌리기',
14579
+ duration: 6000,
14580
+ onAction: () => {
14581
+ blockedUsers = before.users;
14582
+ blockedKeywords = before.keywords;
14583
+ blockedTitles = before.titles;
14584
+ saveUsers();
14585
+ saveKeywords();
14586
+ saveTitles();
14587
+ refreshAfterBlockChange();
14588
+ rerenderModal();
14589
+ }
14590
+ });
14591
+ };
14592
+
14593
+ const renderCleanupHelper = area => {
14594
+ const { idle, redundant, tracked } = cleanupSuggestions();
14595
+ const count = idle.length + redundant.length;
14596
+ const waitDays = Math.max(0, CLEANUP_IDLE_DAYS - tracked);
14597
+
14598
+ if (!count && !waitDays) {
14599
+ return;
14600
+ }
14601
+
14602
+ area.appendChild(makeFoldSection({
14603
+ id: 'cleanup',
14604
+ title: `🧹 정리 도우미${count ? '' : ' · 준비 중'}`,
14605
+ count,
14606
+ defaultOpen: false,
14607
+ render: body => {
14608
+ if (waitDays) {
14609
+ body.appendChild(makeNote(`어떤 차단이 실제로 쓰이는지 기록을 모으는 중입니다. ${waitDays}일 뒤부터 오래 안 걸린 항목을 추천합니다.`));
14610
+ }
14611
+
14612
+ const group = (title, items, meta) => {
14613
+ if (!items.length) {
14614
+ return;
14615
+ }
14616
+
14617
+ body.appendChild(makeSectionTitle(`${title} · ${items.length}개`));
14618
+ renderPaged(body, items, item => {
14619
+ const info = describeHitKey(item.key);
14620
+
14621
+ return makeRow({
14622
+ title: info.label,
14623
+ chip: info.kind,
14624
+ chipClass: 'scope',
14625
+ meta: meta(item),
14626
+ actions: [{ label: '해제', className: 'on', onClick: () => {
14627
+ removeBlockKeys([item.key]);
14628
+ rerenderModal();
14629
+ } }]
14630
+ });
14631
+ });
14632
+ body.appendChild(makeButton(`${title} 모두 해제`, 'wide', () => {
14633
+ if (confirm(`${title} ${items.length}개를 차단 목록에서 뺄까요? (바로 되돌릴 수 있습니다)`)) {
14634
+ removeBlockKeys(items.map(item => item.key));
14635
+ rerenderModal();
14636
+ }
14637
+ }));
14638
+ };
14639
+
14640
+ group('필요 없는 단어', redundant, item => `"${item.cover}" 가 이미 걸러 줌`);
14641
+ group(`${CLEANUP_IDLE_DAYS}일 넘게 안 걸린 항목`, idle, item => `${item.days}일 동안 걸린 글·댓글 없음 (모든 기기)`);
14642
+ }
14643
+ }));
14644
+ };
14645
+
14646
+ // =========================================================
14647
+
14648
+ // =========================================================
14649
+
14650
+ const systemNotifySupported = () => typeof window.Notification === 'function';
14651
+
14652
+ const canSystemNotify = () =>
14653
+ settings.alarmSystemNotify === true && systemNotifySupported() && window.Notification.permission === 'granted';
14654
+
14655
+ const showSystemAlarm = (item, extra = 0) => {
14656
+ try {
14657
+ const note = new window.Notification(`디시 알림 · ${item.tag}`, {
14658
+ body: extra ? `${item.text}\n외 ${extra}개` : item.text,
14659
+ tag: `dcx-${item.key}`,
14660
+ silent: false
14661
+ });
14662
+
14663
+ note.onclick = () => {
14664
+ try {
14665
+ window.focus();
14666
+ } catch {
14667
+ }
14668
+
14669
+ note.close();
14670
+
14671
+ if (extra) {
14672
+ openBlockModal('recent');
14673
+ } else {
14674
+ openAlarm(item);
14675
+ }
14676
+ };
14677
+ } catch {
14678
+ }
14679
+ };
14680
+
14681
+ const requestSystemNotify = async () => {
14682
+ if (!systemNotifySupported()) {
14683
+ showToast('이 브라우저는 기기 알림을 지원하지 않습니다');
14684
+ return false;
14685
+ }
14686
+
14687
+ let permission = window.Notification.permission;
14688
+
14689
+ if (permission === 'default') {
14690
+ try {
14691
+ permission = await window.Notification.requestPermission();
14692
+ } catch {
14693
+ permission = 'denied';
14694
+ }
14695
+ }
14696
+
14697
+ if (permission !== 'granted') {
14698
+ showToast('🔕 브라우저에서 이 사이트의 알림을 허용해야 기기 알림을 띄울 수 있습니다', { duration: 6000 });
14699
+ return false;
14700
+ }
14701
+
14702
+ showToast('🔔 다른 탭을 보고 있어도 디시 알림을 기기 알림으로 띄웁니다');
14703
+ return true;
14704
+ };
14705
+
14706
+ // =========================================================
14707
+
14708
+ // =========================================================
14709
+
14710
+ const PREVIEW_DELAY = 450;
14711
+ const PREVIEW_CLOSE_DELAY = 380;
14712
+ const PREVIEW_CACHE_MS = 5 * 60 * 1000;
14713
+ const PREVIEW_CACHE_MAX = 30;
14714
+ const PREVIEW_TITLE_SELECTOR = 'table.gall_list td.gall_tit > a:not(.reply_numbox)';
14715
+ const PREVIEW_MOVIE_RE = /^https:\/\/gall\.dcinside\.com\/board\/movie\//i;
14716
+
14717
+ const previewCache = new Map();
14718
+ let previewEl = null;
14719
+ let previewKey = '';
14720
+ let previewHref = '';
14721
+ let previewAnchor = null;
14722
+ let previewHoverTimer = null;
14723
+ let previewCloseTimer = null;
14724
+ let previewThemeObserver = null;
14725
+
14726
+ const previewEnabled = () => IS_PC_SITE && settings.hoverPreview !== false;
14727
+ const previewOpen = () => !!previewEl && previewEl.classList.contains('show');
14728
+
14729
+ const ensurePreviewEl = () => {
14730
+ if (previewEl && previewEl.isConnected) {
14731
+ return previewEl;
14732
+ }
14733
+
14734
+ previewEl = h('div', 'dc-ui');
14735
+ previewEl.id = 'dc-preview';
14736
+ previewEl.setAttribute('role', 'dialog');
14737
+ previewEl.setAttribute('aria-label', '글 미리보기');
14738
+ previewEl.innerHTML =
14739
+ '<div class="dc-pv-head">' +
14740
+ '<div class="dc-pv-title"></div>' +
14741
+ '<div class="dc-pv-meta"></div>' +
14742
+ '<div class="dc-pv-actions"></div>' +
14743
+ '<div class="dc-pv-notes"></div>' +
14744
+ '</div>' +
14745
+ '<div class="dc-pv-body"></div>';
14746
+ previewEl.addEventListener('mouseenter', () => clearTimeout(previewCloseTimer));
14747
+ previewEl.addEventListener('mouseleave', () => schedulePreviewClose());
14748
+ document.body.appendChild(previewEl);
14749
+
14750
+ return previewEl;
14751
+ };
14752
+
14753
+ const stopPreviewTheme = () => {
14754
+ previewThemeObserver?.disconnect();
14755
+ previewThemeObserver = null;
14756
+ };
14757
+
14758
+ const closePreview = () => {
14759
+ clearTimeout(previewHoverTimer);
14760
+ clearTimeout(previewCloseTimer);
14761
+ stopPreviewTheme();
14762
+
14763
+ if (previewEl) {
14764
+ previewEl.classList.remove('show');
14765
+
14766
+ previewEl.querySelector('.dc-pv-body').textContent = '';
14767
+ previewEl.querySelector('.dc-pv-notes').textContent = '';
14768
+ }
14769
+
14770
+ previewAnchor?.classList.remove('dc-pv-anchor');
14771
+ previewAnchor = null;
14772
+ previewKey = '';
14773
+ };
14774
+
14775
+ const schedulePreviewClose = () => {
14776
+ clearTimeout(previewCloseTimer);
14777
+ previewCloseTimer = setTimeout(closePreview, PREVIEW_CLOSE_DELAY);
14778
+ };
14779
+
14780
+ const goToPreviewPost = () => {
14781
+ const href = previewHref;
14782
+
14783
+ closePreview();
14784
+
14785
+ if (href) {
14786
+ location.href = href;
14787
+ }
14788
+ };
14789
+
14790
+ const cleanPreviewBody = source => {
14791
+ const body = source.cloneNode(true);
14792
+ let hiddenMedia = 0;
14793
+
14794
+ body.removeAttribute('style');
14795
+
14796
+ body.querySelectorAll('script, style, link, object, embed, form, noscript, .dc-ui, #zzbang_div').forEach(node => node.remove());
14797
+
14798
+ body.querySelectorAll('iframe').forEach(node => {
14799
+ let src = node.getAttribute('src') || '';
14800
+
14801
+ try {
14802
+ src = new URL(src, 'https://gall.dcinside.com/').href;
14803
+ } catch {
14804
+ src = '';
14805
+ }
14806
+
14807
+ if (PREVIEW_MOVIE_RE.test(src)) {
14808
+ node.setAttribute('src', src);
14809
+ node.removeAttribute('style');
14810
+ node.removeAttribute('width');
14811
+ node.removeAttribute('height');
14812
+ node.setAttribute('allowfullscreen', '');
14813
+ node.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
14814
+ node.className = 'dc-pv-movie';
14815
+ return;
14816
+ }
14817
+
14818
+ const link = body.ownerDocument.createElement('a');
14819
+
14820
+ link.href = src;
14821
+ link.textContent = '🎬 삽입된 동영상 열기';
14822
+ link.className = 'dc-pv-embed';
14823
+ node.replaceWith(link);
14824
+ });
14825
+
14826
+ body.querySelectorAll('*').forEach(node => {
14827
+ [...node.attributes].forEach(attr => {
14828
+ if (/^on/i.test(attr.name) || (/^(href|src)$/i.test(attr.name) && /^\s*javascript:/i.test(attr.value))) {
14829
+ node.removeAttribute(attr.name);
14830
+ }
14831
+ });
14832
+ });
14833
+
14834
+ body.querySelectorAll('img, video').forEach(media => {
14835
+ const lazy = media.getAttribute('data-original') || media.getAttribute('data-src') || media.getAttribute('data-lazy-src');
14836
+
14837
+ if (media.tagName === 'IMG' && lazy && /^https?:\/\//.test(lazy)) {
14838
+ media.setAttribute('src', lazy);
14839
+ }
14840
+
14841
+ const candidates = collectMediaCandidates(media);
14842
+
14843
+ if (isDcconElement(media, candidates)) {
14844
+ if (settings.blockDccon !== false) {
14845
+ media.remove();
14846
+ }
14847
+
14848
+ return;
14849
+ }
14850
+
14851
+ if (findBlockedMediaKey(resolveMediaIds(media, candidates))) {
14852
+ const stub = body.ownerDocument.createElement('div');
14853
+
14854
+ stub.className = 'dc-pv-blocked';
14855
+ stub.textContent = '🚫 차단한 이미지';
14856
+ (media.closest('a') || media).replaceWith(stub);
14857
+ hiddenMedia += 1;
14858
+ return;
14859
+ }
14860
+
14861
+ media.removeAttribute('loading');
14862
+ media.removeAttribute('width');
14863
+ media.removeAttribute('height');
14864
+
14865
+ if (media.tagName === 'VIDEO') {
14866
+ media.setAttribute('playsinline', '');
14867
+
14868
+ if (media.hasAttribute('loop') && media.hasAttribute('muted')) {
14869
+ media.setAttribute('autoplay', '');
14870
+ media.muted = true;
14871
+ } else {
14872
+ media.removeAttribute('autoplay');
14873
+ media.setAttribute('controls', '');
14874
+ media.setAttribute('preload', 'metadata');
14875
+ }
14876
+ }
14877
+ });
14878
+
14879
+ return { body, hiddenMedia };
14880
+ };
14881
+
14882
+ const previewFrameCss = () => {
14883
+ const root = getComputedStyle(document.documentElement);
14884
+ const token = name => root.getPropertyValue(name).trim();
14885
+ const font = getComputedStyle(document.body).fontFamily || 'sans-serif';
14886
+ const dark = document.documentElement.classList.contains('dc-dark');
14887
+
14888
+ return `
14889
+ html { color-scheme: ${dark ? 'dark' : 'light'}; }
14890
+ html, body { margin: 0; padding: 0; background: ${token('--dc-surface')}; color: ${token('--dc-text')}; }
14891
+ body { padding: 14px 18px 28px; font: 14px/1.7 ${font}; word-break: break-word; overflow-wrap: anywhere; cursor: pointer; }
14892
+ ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-thumb { background: ${token('--dc-border')}; border-radius: 4px; }
14893
+ * { max-width: 100% !important; box-sizing: border-box; white-space: normal !important; }
14894
+ pre, pre * { white-space: pre-wrap !important; }
14895
+ [style*="width"] { width: auto !important; }
14896
+ p { margin: 0 0 4px; }
14897
+ img, video { display: block; height: auto !important; margin: 6px 0; border-radius: 6px; }
14898
+ a { color: ${token('--dc-accent-ink')}; cursor: pointer; }
14899
+ video, iframe, a, button { cursor: auto; }
14900
+ a { cursor: pointer; }
14901
+ .dc-pv-movie { display: block; width: 100% !important; aspect-ratio: 16 / 9; height: auto; border: 0; border-radius: 8px; background: #000; }
14902
+ .dc-pv-embed { display: inline-block; margin: 6px 0; font-weight: 700; }
14903
+ .dc-pv-blocked { margin: 6px 0; padding: 14px; border: 1px dashed ${token('--dc-border')}; border-radius: 8px; color: ${token('--dc-text-dim')}; font-size: 12px; text-align: center; }
14904
+ ${dark ? `[style*="background"] { background: transparent !important; }
14905
+ [style*="color: rgb(0, 0, 0)"], [style*="color:#000"], [style*="color: #000"], [style*="color:black"], font[color="#000000"] { color: inherit !important; }` : ''}
14906
+ `;
14907
+ };
14908
+
14909
+ const mountPreviewFrame = (container, data, href) => {
14910
+ const { body, hiddenMedia } = cleanPreviewBody(data.source);
14911
+ const frame = document.createElement('iframe');
14912
+
14913
+ frame.className = 'dc-pv-frame';
14914
+ frame.setAttribute('title', '글 본문');
14915
+ container.appendChild(frame);
14916
+
14917
+ const win = frame.contentWindow;
14918
+ const doc = frame.contentDocument;
14919
+
14920
+ if (!win || !doc) {
14921
+ return hiddenMedia;
14922
+ }
14923
+
14924
+ doc.open();
14925
+ doc.write('<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style id="dc-pv-css"></style></head><body></body></html>');
14926
+ doc.close();
14927
+
14928
+ try {
14929
+ const target = new URL(href, location.href);
14930
+
14931
+ win.history.replaceState(null, '', target.pathname + target.search);
14932
+ } catch {
14933
+ }
14934
+
14935
+ doc.getElementById('dc-pv-css').textContent = previewFrameCss();
14936
+ doc.body.appendChild(doc.importNode(body, true));
14937
+
14938
+ doc.querySelectorAll('video[data-src]').forEach(video => {
14939
+ const gif = video.getAttribute('data-src');
14940
+ let swapped = false;
14941
+ const toGif = () => {
14942
+ if (swapped || !video.isConnected) {
14943
+ return;
14944
+ }
14945
+
14946
+ swapped = true;
14947
+
14948
+ const img = doc.createElement('img');
14949
+
14950
+ img.src = gif;
14951
+ img.alt = '움짤';
14952
+ video.replaceWith(img);
14953
+ };
14954
+
14955
+ if (!video.canPlayType('video/mp4')) {
14956
+ toGif();
14957
+ return;
14958
+ }
14959
+
14960
+ video.addEventListener('error', toGif, true);
14961
+ video.querySelectorAll('source').forEach(source => source.addEventListener('error', toGif));
14962
+ });
14963
+
14964
+ doc.addEventListener('click', event => {
14965
+ const target = event.target;
14966
+
14967
+ if (target && target.closest && target.closest('a[href], video, iframe, button, input, textarea, select, summary')) {
14968
+ return;
14969
+ }
14970
+
14971
+ event.preventDefault();
14972
+ goToPreviewPost();
14973
+ });
14974
+
14975
+ doc.addEventListener('keydown', event => {
14976
+ if (event.key === 'Escape') {
14977
+ closePreview();
14978
+ }
14979
+ });
14980
+
14981
+ doc.documentElement.style.overscrollBehavior = 'contain';
14982
+
14983
+ stopPreviewTheme();
14984
+ previewThemeObserver = new MutationObserver(() => {
14985
+ const css = doc.getElementById('dc-pv-css');
14986
+
14987
+ if (css) {
14988
+ css.textContent = previewFrameCss();
14989
+ }
14990
+ });
14991
+ previewThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
14992
+
14993
+ return hiddenMedia;
14994
+ };
14995
+
14996
+ const readPreviewDoc = (html, key) => {
14997
+ const doc = new DOMParser().parseFromString(html, 'text/html');
14998
+ const source = doc.querySelector('.write_div') || doc.querySelector(POST_BODY_SELECTOR);
14999
+ const head = doc.querySelector('.gallview_head');
15000
+
15001
+ if (!source) {
15002
+ return { missing: true };
15003
+ }
15004
+
15005
+ const writer = head?.querySelector('.gall_writer');
15006
+ const text = normalizeWord(`${head?.querySelector('.title_subject')?.textContent || ''} ${source.textContent}`).slice(0, BODY_TEXT_LIMIT);
15007
+ const word = findWord(blockedKeywords, text);
15008
+
15009
+ if (key) {
15010
+ bodyScanCache[key] = { s: bodyScanSignature(), w: word, m: '', t: Date.now() };
15011
+ saveBodyScanSoon();
15012
+ }
15013
+
15014
+ return {
15015
+ title: cleanText(head?.querySelector('.title_subject')?.textContent || ''),
15016
+ head: cleanText(head?.querySelector('.title_headtext')?.textContent || '').replace(/^\[|\]$/g, ''),
15017
+ nick: writer?.getAttribute('data-nick') || cleanText(writer?.querySelector('.nickname')?.textContent || ''),
15018
+ ip: writer?.getAttribute('data-ip') || '',
15019
+ code: writer?.getAttribute('data-uid') || '',
15020
+ date: head?.querySelector('.gall_date')?.getAttribute('title') || cleanText(head?.querySelector('.gall_date')?.textContent || ''),
15021
+ stats: (cleanText(head?.querySelector('.fr')?.textContent || '').match(/(조회|추천|댓글)\s*[\d,]+/g) || []).join(' · '),
15022
+ source,
15023
+ word
15024
+ };
15025
+ };
15026
+
15027
+ const renderPreview = (data, href, key) => {
15028
+ const el = ensurePreviewEl();
15029
+ const titleEl = el.querySelector('.dc-pv-title');
15030
+ const metaEl = el.querySelector('.dc-pv-meta');
15031
+ const actions = el.querySelector('.dc-pv-actions');
15032
+ const notes = el.querySelector('.dc-pv-notes');
15033
+ const bodyEl = el.querySelector('.dc-pv-body');
15034
+
15035
+ previewHref = href;
15036
+ actions.textContent = '';
15037
+ notes.textContent = '';
15038
+ bodyEl.textContent = '';
15039
+ stopPreviewTheme();
15040
+
15041
+ if (data.missing) {
15042
+ titleEl.textContent = '본문을 불러올 수 없습니다';
15043
+ metaEl.textContent = '삭제됐거나 성인 인증이 필요한 글입니다.';
15044
+ } else {
15045
+ titleEl.textContent = `${data.head ? `[${data.head}] ` : ''}${data.title || '(제목 없음)'}`;
15046
+ metaEl.textContent = [data.nick && `${data.nick}${data.ip ? `(${data.ip})` : ''}`, data.date, data.stats].filter(Boolean).join(' · ');
15047
+
15048
+ const showBody = () => {
15049
+ const hidden = mountPreviewFrame(bodyEl, data, href);
15050
+
15051
+ if (hidden) {
15052
+ notes.appendChild(h('div', 'dc-pv-note', `차단한 이미지 ${hidden}개를 뺐습니다`));
15053
+ }
15054
+ };
15055
+
15056
+ if (data.word) {
15057
+ const warn = h('div', 'dc-pv-warn', `🚫 본문에 차단 단어 "${data.word}" 가 있어 가렸습니다${settings.prefetchBodies !== false ? ' · 목록에서도 가려집니다' : ''}`);
15058
+
15059
+ warn.appendChild(makeButton('그래도 보기', 'mini', () => {
15060
+ warn.remove();
15061
+ showBody();
15062
+ }));
15063
+ bodyEl.appendChild(warn);
15064
+ } else {
15065
+ showBody();
15066
+ }
15067
+ }
15068
+
15069
+ const open = h('a', 'dc-btn mini primary', '글 열기');
15070
+
15071
+ open.href = href;
15072
+ open.addEventListener('click', event => {
15073
+ if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
15074
+ event.preventDefault();
15075
+ goToPreviewPost();
15076
+ }
15077
+ });
15078
+ actions.appendChild(open);
15079
+
15080
+ if (!data.missing && key) {
15081
+ const match = /[?&]id=([^&]+)&no=(\d+)/.exec(href) || [];
15082
+ const kind = /\/mgallery\//.test(href) ? 'M' : /\/mini\//.test(href) ? 'N' : /\/person\//.test(href) ? 'P' : 'G';
15083
+ const info = match[1] ? normalizeScrap({ k: kind, id: decodeURIComponent(match[1]), no: match[2], t: data.title, g: '', d: Date.now() }) : null;
15084
+
15085
+ if (info && isValidScrap(info)) {
15086
+ const scrapBtn = makeButton(isScrapped(info) ? '📌 스크랩됨' : '📌 스크랩', 'mini', () => {
15087
+ toggleScrap(info);
15088
+ scrapBtn.textContent = isScrapped(info) ? '📌 스크랩됨' : '📌 스크랩';
15089
+ });
15090
+
15091
+ actions.appendChild(scrapBtn);
15092
+ }
15093
+
15094
+ const userKey = data.code ? `id::${data.code}` : data.ip ? `ip::${data.ip}` : data.nick ? `nick::${data.nick}` : '';
15095
+
15096
+ if (userKey && !blockedUsers.includes(userKey)) {
15097
+ actions.appendChild(makeButton('🚫 작성자 차단', 'mini danger-ghost', () => {
15098
+ blockedUsers = sortUserKeys([...blockedUsers, userKey]);
15099
+ saveUsers();
15100
+ refreshAfterBlockChange();
15101
+ closePreview();
15102
+ offerUndo(`✓ ${describeHitKey(userKey).kind} ${describeHitKey(userKey).label} 차단`, () => {
15103
+ blockedUsers = blockedUsers.filter(item => item !== userKey);
15104
+ saveUsers();
15105
+ refreshAfterBlockChange();
15106
+ });
15107
+ }));
15108
+ }
15109
+ }
15110
+
15111
+ actions.appendChild(h('span', 'dc-pv-hint', '본문을 누르면 글로 이동'));
15112
+
15113
+ const close = makeButton('✕', 'mini dc-pv-close', closePreview);
15114
+
15115
+ close.setAttribute('aria-label', '미리보기 닫기');
15116
+ actions.appendChild(close);
15117
+ };
15118
+
15119
+ const showPreviewFor = async anchor => {
15120
+ if (!previewEnabled() || !anchor || !anchor.isConnected) {
15121
+ return;
15122
+ }
15123
+
15124
+ const row = anchor.closest('tr');
15125
+
15126
+ if (row && ((row.classList.contains('dc-user-blocked') && !row.classList.contains('dc-user-revealed')) || row.classList.contains('dc-head-hidden'))) {
15127
+ return;
15128
+ }
15129
+
15130
+ const href = anchor.href;
15131
+ const key = extractPostPath(href);
15132
+
15133
+ if (!key) {
15134
+ return;
15135
+ }
15136
+
15137
+ previewAnchor?.classList.remove('dc-pv-anchor');
15138
+ previewAnchor = anchor;
15139
+ previewKey = key;
15140
+ previewHref = href;
15141
+ anchor.classList.add('dc-pv-anchor');
15142
+
15143
+ const el = ensurePreviewEl();
15144
+
15145
+ el.classList.add('show');
15146
+
15147
+ const cached = previewCache.get(key);
15148
+
15149
+ if (cached && Date.now() - cached.at < PREVIEW_CACHE_MS) {
15150
+ renderPreview(cached.data, href, key);
15151
+ return;
15152
+ }
15153
+
15154
+ stopPreviewTheme();
15155
+ el.querySelector('.dc-pv-title').textContent = cleanText(anchor.textContent);
15156
+ el.querySelector('.dc-pv-meta').textContent = '';
15157
+ el.querySelector('.dc-pv-actions').textContent = '';
15158
+ el.querySelector('.dc-pv-notes').textContent = '';
15159
+
15160
+ const body = el.querySelector('.dc-pv-body');
15161
+ const paused = netPausedUntil();
15162
+
15163
+ body.textContent = '';
15164
+
15165
+ if (paused) {
15166
+ body.appendChild(h('div', 'dc-pv-note', `⏸ 디시 요청 제한 중이라 ${Math.max(1, Math.ceil((paused - Date.now()) / 60000))}분 뒤에 미리볼 수 있습니다`));
15167
+ return;
15168
+ }
15169
+
15170
+ body.appendChild(h('div', 'dc-pv-loading', '불러오는 중…'));
15171
+
15172
+ try {
15173
+ const html = await siteFetchText(href, { credentials: 'same-origin', cache: 'no-cache' });
15174
+ const data = readPreviewDoc(html, key);
15175
+
15176
+ previewCache.set(key, { at: Date.now(), data });
15177
+
15178
+ while (previewCache.size > PREVIEW_CACHE_MAX) {
15179
+ previewCache.delete(previewCache.keys().next().value);
15180
+ }
15181
+
15182
+ if (previewKey === key) {
15183
+ renderPreview(data, href, key);
15184
+
15185
+ if (data.word && settings.prefetchBodies !== false) {
15186
+ refreshAfterBlockChange();
15187
+ }
15188
+ }
15189
+ } catch (error) {
15190
+ if (previewKey !== key) {
15191
+ return;
15192
+ }
15193
+
15194
+ body.textContent = '';
15195
+ body.appendChild(h('div', 'dc-pv-note', error instanceof NetPausedError
15196
+ ? '⏸ 디시 요청 제한 중이라 잠시 뒤에 미리볼 수 있습니다'
15197
+ : '불러오지 못했습니다. 잠시 뒤 다시 올려 보세요.'));
15198
+ }
15199
+ };
15200
+
15201
+ const initHoverPreview = () => {
15202
+ if (!IS_PC_SITE) {
15203
+ return;
15204
+ }
15205
+
15206
+ document.addEventListener('mouseover', event => {
15207
+ if (!previewEnabled()) {
15208
+ return;
15209
+ }
15210
+
15211
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15212
+
15213
+ if (!anchor || anchor.closest('.dc-ui')) {
15214
+ return;
15215
+ }
15216
+
15217
+ if (anchor === previewAnchor) {
15218
+ clearTimeout(previewCloseTimer);
15219
+ return;
15220
+ }
15221
+
15222
+ clearTimeout(previewHoverTimer);
15223
+ previewHoverTimer = setTimeout(() => {
15224
+ clearTimeout(previewCloseTimer);
15225
+ showPreviewFor(anchor);
15226
+ }, PREVIEW_DELAY);
15227
+ });
15228
+
15229
+ document.addEventListener('mouseout', event => {
15230
+ const anchor = event.target instanceof Element ? event.target.closest(PREVIEW_TITLE_SELECTOR) : null;
15231
+
15232
+ if (!anchor || (event.relatedTarget instanceof Node && anchor.contains(event.relatedTarget))) {
15233
+ return;
15234
+ }
15235
+
15236
+ clearTimeout(previewHoverTimer);
15237
+
15238
+ if (previewOpen() && !(event.relatedTarget instanceof Node && previewEl.contains(event.relatedTarget))) {
15239
+ schedulePreviewClose();
15240
+ }
15241
+ });
15242
+
15243
+ document.addEventListener('mousedown', event => {
15244
+ if (previewOpen() && !(event.target instanceof Node && previewEl.contains(event.target))) {
15245
+ closePreview();
15246
+ }
15247
+ }, true);
15248
+
15249
+ window.addEventListener('scroll', () => {
15250
+ if (previewOpen() && !previewEl.matches(':hover')) {
15251
+ closePreview();
15252
+ }
15253
+ }, { passive: true });
15254
+
15255
+ window.addEventListener('resize', () => {
15256
+ if (previewOpen()) {
15257
+ closePreview();
15258
+ }
15259
+ });
15260
+
15261
+ document.addEventListener('keydown', event => {
15262
+ if (event.key === 'Escape' && previewOpen()) {
15263
+ closePreview();
15264
+ }
15265
+ });
15266
+
15267
+ window.addEventListener('pagehide', closePreview);
15268
+ };
15269
+
15270
+ const togglePreviewForCursor = () => {
15271
+ if (!previewEnabled()) {
15272
+ return false;
15273
+ }
15274
+
15275
+ if (previewOpen()) {
15276
+ closePreview();
15277
+ return true;
15278
+ }
15279
+
15280
+ const anchor = keyCursor && keyCursor.isConnected ? keyCursor.querySelector('td.gall_tit > a:not(.reply_numbox)') : null;
15281
+
15282
+ if (!anchor) {
15283
+ return false;
15284
+ }
15285
+
15286
+ showPreviewFor(anchor);
15287
+ return true;
15288
+ };
14162
15289
  // =========================================================
14163
15290
  // 14. 플로팅 버튼
14164
15291
  // =========================================================
@@ -15147,6 +16274,7 @@
15147
16274
  initRecent();
15148
16275
  initSiteAlarms();
15149
16276
  initKeyboardShortcuts();
16277
+ initHoverPreview();
15150
16278
  restoreListSnapshot();
15151
16279
  processElements();
15152
16280