for-humanity 0.4.4 → 0.5.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +5 -5
  3. package/dist/browser.js +39 -0
  4. package/dist/cli.css +350 -262
  5. package/dist/cli.js +1123 -363
  6. package/package.json +8 -11
  7. package/src/component/widget/{shell → cursor-face}/_constant/cursor-face.ts +5 -0
  8. package/src/component/widget/cursor-face/wg-cursor-face.css +15 -0
  9. package/src/component/widget/cursor-face/wg-cursor-face.tsx +153 -0
  10. package/src/component/widget/navigation/_constant/navigation-data-schema.ts +35 -0
  11. package/src/component/widget/navigation/_constant/navigation.ts +9 -0
  12. package/src/component/widget/navigation/_function/restore-navigation-scroll.ts +59 -0
  13. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-branches.ts +2 -2
  14. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-group-branches.ts +3 -3
  15. package/src/{content → component/widget/navigation/_function}/to-doc-groups/to-doc-groups.ts +1 -1
  16. package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +6 -0
  17. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +14 -0
  18. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +14 -0
  19. package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +25 -0
  20. package/src/component/widget/navigation/_function/to-navigation-json.ts +8 -0
  21. package/src/component/widget/{shell → navigation}/_function/to-toc-groups.ts +2 -2
  22. package/src/{type → component/widget/navigation/_type}/doc-group.ts +1 -1
  23. package/src/component/widget/navigation/_type/navigation-data.ts +83 -0
  24. package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +21 -0
  25. package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +28 -0
  26. package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +48 -0
  27. package/src/component/widget/{shell/_wg-shell-nav-list.css → navigation/_wg-navigation-nav-list.css} +24 -16
  28. package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +106 -0
  29. package/src/component/widget/{shell/_wg-shell-nav.css → navigation/_wg-navigation-nav.css} +9 -3
  30. package/src/component/widget/navigation/_wg-navigation-nav.tsx +43 -0
  31. package/src/component/widget/navigation/_wg-navigation-scroll.tsx +117 -0
  32. package/src/component/widget/{shell/_wg-shell-toc.css → navigation/_wg-navigation-toc.css} +39 -24
  33. package/src/component/widget/navigation/_wg-navigation-toc.tsx +155 -0
  34. package/src/component/widget/navigation/wg-navigation.css +282 -0
  35. package/src/component/widget/navigation/wg-navigation.tsx +479 -0
  36. package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +1 -1
  37. package/src/component/widget/shell/wg-shell.css +13 -269
  38. package/src/component/widget/shell/wg-shell.tsx +16 -97
  39. package/src/component/widget/shell-controls/wg-shell-controls.css +4 -0
  40. package/src/component/widget/shell-controls/wg-shell-controls.tsx +170 -0
  41. package/src/constant/asset.ts +0 -5
  42. package/src/entry/browser/browser.tsx +22 -0
  43. package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
  44. package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
  45. package/src/{content → entry/cli/_function}/read-docs.ts +1 -1
  46. package/src/{content → entry/cli/_function}/read-home.ts +1 -1
  47. package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +105 -30
  48. package/src/{cli.ts → entry/cli/cli.ts} +10 -11
  49. package/src/store/navigation/_constant/tree.ts +6 -0
  50. package/src/store/navigation/create-navigation-stores.test.ts +92 -0
  51. package/src/store/navigation/create-navigation-stores.ts +73 -0
  52. package/src/store/navigation/navigation-state.ts +46 -0
  53. package/src/store/navigation/navigation-storage.ts +14 -0
  54. package/src/store/navigation/to-navigation-scroll-state.ts +42 -0
  55. package/src/store/navigation/to-navigation-tree-state.ts +19 -0
  56. package/src/store/navigation/use-navigation-tree-store.ts +12 -0
  57. package/src/style/token.css +3 -2
  58. package/src/type/doc-content.ts +1 -1
  59. package/src/type/doc-file-data.ts +1 -1
  60. package/src/{component/widget/shell/_type → type}/doc-outline.ts +1 -1
  61. package/src/type/site-assets.ts +2 -2
  62. package/dist/client.js +0 -7
  63. package/src/client.ts +0 -335
  64. package/src/component/widget/shell/_constant/navigation.ts +0 -9
  65. package/src/component/widget/shell/_function/bind-mobile-navigation.ts +0 -147
  66. package/src/component/widget/shell/_function/bind-navigation-overflow.ts +0 -47
  67. package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -84
  68. package/src/component/widget/shell/_function/restore-navigation-scroll.ts +0 -47
  69. package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
  70. package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
  71. package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
  72. /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
  73. /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
  74. /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
  75. /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
  76. /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
  77. /package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +0 -0
  78. /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
  79. /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
  80. /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
  81. /package/src/{component/widget/prose/_type → type}/doc-section.ts +0 -0
@@ -0,0 +1,19 @@
1
+ import type {NavigationTreeState} from "@/store/navigation/navigation-state";
2
+
3
+ /**
4
+ * persist 저장 자료에서 boolean 접힘 선택만 수용
5
+ */
6
+ export const toNavigationTreeState = (value: unknown): NavigationTreeState => {
7
+ if (
8
+ typeof value !== "object" ||
9
+ value === null ||
10
+ !("collapsed" in value) ||
11
+ typeof value.collapsed !== "object" ||
12
+ value.collapsed === null ||
13
+ Array.isArray(value.collapsed)
14
+ ) {
15
+ return {collapsed: {}};
16
+ }
17
+
18
+ return {collapsed: Object.fromEntries(Object.entries(value.collapsed).filter((entry) => typeof entry[1] === "boolean"))};
19
+ };
@@ -0,0 +1,12 @@
1
+ import {useSyncExternalStore} from "hono/jsx";
2
+ import {tree_server_state} from "@/store/navigation/_constant/tree";
3
+ import type {createNavigationStores} from "@/store/navigation/create-navigation-stores";
4
+
5
+ /**
6
+ * Hono 컴포넌트의 persist 구독 · 브라우저 최초 렌더도 저장된 값을 즉시 소비
7
+ */
8
+ export const useNavigationTreeStore = (tree?: ReturnType<typeof createNavigationStores>["tree"]) => {
9
+ const subscribe = tree === undefined ? () => () => {} : tree.subscribe;
10
+ const getSnapshot = tree === undefined ? () => tree_server_state : tree.getState;
11
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
12
+ };
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * 공통 토큰의 단일 출처 · --app-종류-쓰임
3
3
  * 사용 기준: templates/document/design.md · 테마 분기는 color-scheme에 한정
4
- * 폭 기준 1024px·640px · 공유 사이드바와 본문 배치
4
+ * 폭 기준 1536px·1024px·640px · 양쪽 탐색과 본문 배치
5
5
  */
6
6
 
7
7
  :root {
@@ -102,7 +102,7 @@
102
102
  --app-space-gutter: 28px;
103
103
  --app-space-column: 32px;
104
104
  --app-space-sidebar-gap: 32px;
105
- --app-size-sidebar: 200px;
105
+ --app-size-sidebar: 240px;
106
106
  --app-size-main: 900px;
107
107
  --app-size-page: calc(var(--app-size-main) + (var(--app-size-sidebar) + var(--app-space-sidebar-gap) + var(--app-space-gutter)) * 2);
108
108
  --app-size-brand: calc(var(--app-font-size-title) * var(--app-line-height-title) + var(--app-space-page-top) + var(--app-space-group));
@@ -110,6 +110,7 @@
110
110
  --app-size-menu-button: 44px;
111
111
  --app-size-drawer: 320px;
112
112
  --app-size-navigation-fade: 28px;
113
+ --app-size-tree-toggle: 24px;
113
114
 
114
115
  /* 화면 위쪽 제목 여백 · hash 도착점과 목차 읽는 선의 공통 기준 */
115
116
  --app-space-anchor: 80px;
@@ -1,4 +1,4 @@
1
- import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
1
+ import type {DocOutline} from "@/type/doc-outline";
2
2
 
3
3
  /**
4
4
  * README 홈과 일반 문서가 공유하는 렌더링 결과
@@ -1,6 +1,6 @@
1
- import type {DocSection} from "@/component/widget/prose/_type/doc-section";
2
1
  import type {DocData} from "@/type/doc-data";
3
2
  import type {DocHeading} from "@/type/doc-heading";
3
+ import type {DocSection} from "@/type/doc-section";
4
4
 
5
5
  /**
6
6
  * 문서 처리 중간 계약 · read-docs의 머리말과 rehype의 제목·절 정보
@@ -1,5 +1,5 @@
1
- import type {DocSection} from "@/component/widget/prose/_type/doc-section";
2
1
  import type {DocHeading} from "@/type/doc-heading";
2
+ import type {DocSection} from "@/type/doc-section";
3
3
 
4
4
  /**
5
5
  * 문서 렌더링 결과의 목차 입력
@@ -7,9 +7,9 @@ export interface SiteAssets {
7
7
  */
8
8
  stylePath: string;
9
9
  /**
10
- * 내용 지문으로 구분한 브라우저 스크립트 URL
10
+ * 셸 DOM 뒤에서 동기 실행할 단일 Hono JSX 브라우저 번들
11
11
  */
12
- clientPath: string;
12
+ browserScript: string;
13
13
  /**
14
14
  * 내장 @font-face · head의 style 내용
15
15
  */
package/dist/client.js DELETED
@@ -1,7 +0,0 @@
1
- var E="fh-cursor-face";var b="(width < 1024px)",S="for-humanity:navigation-scroll";var v=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var p=e=>{let t=v(e);for(let o=t?.closest("details");o;o=o.parentElement?.closest("details"))o.open=!0;return t};var q=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),o=document.querySelector("[data-navigation-content]"),r=document.querySelector("[data-navigation-open]"),c=document.querySelector("[data-navigation-close]"),n=e?.parentElement;if(!e||!t||!o||!r||!c||!n)return;let l=matchMedia(b),s=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","false"))},u=()=>{t.close(),s()},g=()=>{!l.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","true"))},a=i=>{let d=t.getBoundingClientRect();i.target===t&&(i.clientX<d.left||i.clientX>d.right||i.clientY<d.top||i.clientY>d.bottom)&&u()},y=i=>{if(!t.open||i.defaultPrevented||i.button!==0||i.metaKey||i.ctrlKey||i.shiftKey||i.altKey||!(i.target instanceof Element))return;let d=i.target.closest("a[href]");if(!(!(d instanceof HTMLAnchorElement)||d.target!==""&&d.target!=="_self"||d.hasAttribute("download"))&&(u(),d.origin===location.origin&&d.pathname===location.pathname&&d.search===location.search&&d.hash)){let f=p(d.hash);f&&(f.tabIndex=-1,f.focus({preventScroll:!0}))}},m=()=>{let i=document.activeElement;if(l.matches){r.classList.add("wg_shell__menuToggle--visible"),e.parentElement!==o&&o.append(e),i instanceof HTMLElement&&e.contains(i)&&r.focus({preventScroll:!0});return}u(),e.parentElement!==n&&n.append(e),i instanceof HTMLElement&&e.contains(i)?i.focus({preventScroll:!0}):(i===r||i instanceof HTMLElement&&t.contains(i))&&n.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),r.classList.remove("wg_shell__menuToggle--visible")};r.addEventListener("click",g),c.addEventListener("click",u),t.addEventListener("close",s),t.addEventListener("click",a),e.addEventListener("click",y),l.addEventListener("change",m),m(),n.classList.add("wg_shell__sidebar--navigationReady")};var C=e=>{let t=document.querySelector("[data-navigation]"),o=document.querySelector('[data-navigation-scroll="documents"]'),r=document.querySelector('[data-navigation-scroll="outline"]');if(!(!t||!o||!r))try{let c=sessionStorage.getItem(e);if(c===null)return;let n=JSON.parse(c),l=[...t.querySelectorAll(".wg_shellNav__root a[href]")].map(s=>`${s.pathname}:${s.textContent}`).join(`
2
- `);if(typeof n!="object"||n===null||!("navigation"in n)||n.navigation!==l)return;"documents"in n&&typeof n.documents=="number"&&Number.isFinite(n.documents)&&n.documents>=0&&(o.scrollTop=n.documents),"pathname"in n&&n.pathname===location.pathname&&"outline"in n&&typeof n.outline=="number"&&Number.isFinite(n.outline)&&n.outline>=0&&(r.scrollTop=n.outline)}catch{}};var N=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector('[data-navigation-scroll="documents"]'),o=document.querySelector('[data-navigation-scroll="outline"]'),r=document.querySelector("[data-navigation-drawer]"),c=document.querySelector("[data-navigation-open]");if(!e||!t||!o||!r||!c)return;let n=matchMedia(b),l={mobile:n.matches},s=[t,o],u=new Map,g=[...e.querySelectorAll(".wg_shellNav__root a[href]")].map(m=>`${m.pathname}:${m.textContent}`).join(`
3
- `),a=()=>{if(l.mobile!==n.matches||n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`;u.set(m,new Map(s.map(i=>[i,i.scrollTop])));try{sessionStorage.setItem(m,JSON.stringify({navigation:g,pathname:location.pathname,documents:t.scrollTop,outline:o.scrollTop}))}catch{}},y=()=>{if(l.mobile=n.matches,n.matches&&!r.open)return;let m=`${S}:${n.matches?"drawer":"desktop"}`,i=u.get(m);if(i===void 0)C(m);else for(let d of s){let f=i.get(d);f!==void 0&&(d.scrollTop=f)}a()};for(let m of s)m.addEventListener("scroll",a,{passive:!0});e.addEventListener("click",a,{capture:!0}),r.addEventListener("cancel",a),r.querySelector("[data-navigation-close]")?.addEventListener("click",a,{capture:!0}),r.addEventListener("click",a,{capture:!0}),c.addEventListener("click",y),n.addEventListener("change",y),addEventListener("pagehide",a),a()};q();N();var h=document.querySelector("[data-cursor-face]");if(h){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},o=()=>{cancelAnimationFrame(t.frame),t.frame=0,h.classList.remove("wg_shell__cursorFace--visible")},r=()=>{e.matches||o()},c=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,h.style.transform=`translate3d(
4
- ${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-h.width-16))}px,
5
- ${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-h.height-16))}px,
6
- 0
7
- )`,h.classList.add("wg_shell__cursorFace--visible")}))},n=g=>{if(!(e.matches&&g.pointerType==="mouse")){o();return}t.x=g.clientX,t.y=g.clientY,c()},l=()=>{h.classList.contains("wg_shell__cursorFace--visible")&&c()},s=()=>{if(h.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(E,JSON.stringify({x:t.x,y:t.y}))}catch{}},u=()=>{try{let g=sessionStorage.getItem(E);if(sessionStorage.removeItem(E),g!==null&&e.matches){let a=JSON.parse(g);typeof a=="object"&&a!==null&&"x"in a&&"y"in a&&typeof a.x=="number"&&typeof a.y=="number"&&Number.isFinite(a.x)&&Number.isFinite(a.y)&&(t.x=a.x,t.y=a.y,c())}}catch{}};u(),document.addEventListener("pointermove",n,{passive:!0}),document.addEventListener("pointerdown",n,{passive:!0}),document.documentElement.addEventListener("pointerleave",o),addEventListener("blur",o),addEventListener("resize",l),addEventListener("pagehide",s),addEventListener("pageshow",u),e.addEventListener("change",r)}var H=p(location.hash),L=null,w=0,_=null,k=()=>{clearTimeout(w),_?.abort(),_=null,L?.classList.remove("wg_prose__headingText--highlighted"),L=null},A=e=>{k(),L=e===null?null:e.querySelector(".wg_prose__headingText"),L!==null&&(L.classList.add("wg_prose__headingText--highlighted"),w=window.setTimeout(k,3e3),_=new AbortController,document.addEventListener("scrollend",O,{signal:_.signal}))},O=e=>{e.target===document&&(clearTimeout(w),_?.abort(),_=null,w=window.setTimeout(k,3e3))},K=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=p(e.currentTarget.hash);e.currentTarget.hash===location.hash&&A(t)}},P=()=>{let e=p(location.hash);e?.scrollIntoView(),A(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",K);addEventListener("hashchange",P);var M=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=v(e.hash),o=e.parentElement;return!t||!o?[]:[{heading:t,link:e,item:o,list:o.parentElement,subs:[...o.querySelectorAll("[data-toc-sub-link]")].flatMap(r=>{let c=v(r.hash),n=r.parentElement;return c&&n?[{heading:c,link:r,item:n}]:[]})}]}),x=M.at(0);if(x){let e=()=>{let t=Number.parseFloat(getComputedStyle(x.heading).scrollMarginTop)+1,o=M.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t)??x,r=o.subs.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t),c=M.indexOf(o),n=r===void 0?-1:o.subs.indexOf(r);for(let[l,s]of M.entries()){let u=s===o&&r===void 0;s.link.classList.toggle("wg_shellToc__link--active",u),u?s.link.setAttribute("aria-current","location"):s.link.removeAttribute("aria-current"),s.item.classList.toggle("wg_shellToc__item--trail",s.list===o.list&&l<c),s.item.classList.toggle("wg_shellToc__item--current",s===o);for(let[g,a]of s.subs.entries())a.link.classList.toggle("wg_shellToc__subLink--active",a===r),a===r?a.link.setAttribute("aria-current","location"):a.link.removeAttribute("aria-current"),a.item.classList.toggle("wg_shellToc__subItem--trail",s===o&&g<n),a.item.classList.toggle("wg_shellToc__subItem--current",a===r)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var B=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>B.abort(),{once:!0,passive:!0});var I=async()=>{await document.fonts.ready,H&&!B.signal.aborted&&(H.scrollIntoView({behavior:"instant"}),A(H)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?I():addEventListener("load",I,{once:!0});
package/src/client.ts DELETED
@@ -1,335 +0,0 @@
1
- /*
2
- * 브라우저 동작 · 모바일 탐색, 커서 장식, 읽는 절, hash 위치 보정
3
- * 서버 HTML의 data-* 연결 · React hydration 없음
4
- */
5
-
6
- import {heading_highlight_hold_ms} from "@/component/widget/prose/_constant/heading-highlight";
7
- import {cursor_face_offset_px, cursor_face_storage_key} from "@/component/widget/shell/_constant/cursor-face";
8
- import {reading_line_slack_px} from "@/component/widget/shell/_constant/reading-line";
9
- import {bindMobileNavigation} from "@/component/widget/shell/_function/bind-mobile-navigation";
10
- import {bindNavigationScroll} from "@/component/widget/shell/_function/bind-navigation-scroll";
11
- import {findHashTarget} from "@/util/dom/find-hash-target";
12
- import {revealHashTarget} from "@/util/dom/reveal-hash-target";
13
-
14
- bindMobileNavigation();
15
- bindNavigationScroll();
16
-
17
- const cursorFace = document.querySelector<HTMLImageElement>("[data-cursor-face]");
18
-
19
- if (cursorFace) {
20
- const cursorMedia = matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)");
21
- const position = {x: 0, y: 0, frame: 0};
22
-
23
- /**
24
- * 터치 전환과 화면 이탈 시 예약된 이동도 취소해 장식이 다시 나타나지 않게 함
25
- */
26
- const handleCursorHide = () => {
27
- cancelAnimationFrame(position.frame);
28
- position.frame = 0;
29
- cursorFace.classList.remove("wg_shell__cursorFace--visible");
30
- };
31
-
32
- /**
33
- * 비활성 전환에서만 숨김 · 활성화 알림이 새 마우스 이동을 취소하지 않도록 함
34
- */
35
- const handleCursorMediaChange = () => {
36
- if (!cursorMedia.matches) {
37
- handleCursorHide();
38
- }
39
- };
40
-
41
- /**
42
- * 이동·화면 크기 변경·문서 진입에 같은 위치 계산 적용 · 화면 갱신마다 한 번만 반영
43
- */
44
- const renderCursorFace = () => {
45
- if (position.frame !== 0) {
46
- return;
47
- }
48
-
49
- position.frame = requestAnimationFrame(() => {
50
- position.frame = 0;
51
-
52
- // 포인터 좌표는 실행 중에만 결정 · transform으로 문서 재배치 방지
53
- cursorFace.style.transform = `translate3d(
54
- ${Math.max(0, Math.min(position.x + cursor_face_offset_px, document.documentElement.clientWidth - cursorFace.width - cursor_face_offset_px))}px,
55
- ${Math.max(0, Math.min(position.y + cursor_face_offset_px, document.documentElement.clientHeight - cursorFace.height - cursor_face_offset_px))}px,
56
- 0
57
- )`;
58
- cursorFace.classList.add("wg_shell__cursorFace--visible");
59
- });
60
- };
61
-
62
- /**
63
- * 누른 상태도 마우스 입력으로 처리 · 클릭·텍스트 선택 중에도 얼굴 유지
64
- */
65
- const handleDocumentPointer = (event: PointerEvent) => {
66
- const isMouse = cursorMedia.matches && event.pointerType === "mouse";
67
-
68
- if (!isMouse) {
69
- handleCursorHide();
70
-
71
- return;
72
- }
73
-
74
- position.x = event.clientX;
75
- position.y = event.clientY;
76
- renderCursorFace();
77
- };
78
-
79
- /**
80
- * 이미 보이는 얼굴을 새 화면 크기에 맞춰 이동 · 추가 마우스 이동 불필요
81
- */
82
- const handleCursorResize = () => {
83
- if (cursorFace.classList.contains("wg_shell__cursorFace--visible")) {
84
- renderCursorFace();
85
- }
86
- };
87
-
88
- /**
89
- * 전체 페이지 이동에서도 마지막 좌표 유지 · 저장 실패는 기본 마우스 동작에 영향 없음
90
- */
91
- const handlePageHide = () => {
92
- if (!cursorFace.classList.contains("wg_shell__cursorFace--visible")) {
93
- return;
94
- }
95
-
96
- try {
97
- sessionStorage.setItem(cursor_face_storage_key, JSON.stringify({x: position.x, y: position.y}));
98
- } catch {
99
- // 저장소가 막힌 환경에서도 클릭·선택과 현재 페이지의 장식 유지
100
- }
101
- };
102
-
103
- /**
104
- * 첫 진입과 BFCache 복귀에서 좌표를 한 번 소비 · 이전 문서의 위치가 남지 않도록 삭제
105
- */
106
- const handlePageShow = () => {
107
- try {
108
- const stored = sessionStorage.getItem(cursor_face_storage_key);
109
-
110
- sessionStorage.removeItem(cursor_face_storage_key);
111
-
112
- if (stored !== null && cursorMedia.matches) {
113
- const point: unknown = JSON.parse(stored);
114
- // 속성 타입의 좁히기를 유지하도록 저장소 검증과 사용을 같은 분기에 둠
115
- if (
116
- typeof point === "object" &&
117
- point !== null &&
118
- "x" in point &&
119
- "y" in point &&
120
- typeof point.x === "number" &&
121
- typeof point.y === "number" &&
122
- Number.isFinite(point.x) &&
123
- Number.isFinite(point.y)
124
- ) {
125
- position.x = point.x;
126
- position.y = point.y;
127
- renderCursorFace();
128
- }
129
- }
130
- } catch {
131
- // 저장 실패나 잘못된 좌표는 무시하고 다음 실제 마우스 입력에서 표시
132
- }
133
- };
134
-
135
- handlePageShow();
136
-
137
- document.addEventListener("pointermove", handleDocumentPointer, {passive: true});
138
- document.addEventListener("pointerdown", handleDocumentPointer, {passive: true});
139
- document.documentElement.addEventListener("pointerleave", handleCursorHide);
140
- addEventListener("blur", handleCursorHide);
141
- addEventListener("resize", handleCursorResize);
142
- addEventListener("pagehide", handlePageHide);
143
- addEventListener("pageshow", handlePageShow);
144
- cursorMedia.addEventListener("change", handleCursorMediaChange);
145
- }
146
-
147
- const hashTarget = revealHashTarget(location.hash);
148
-
149
- // 값 조립이 아닌 현재 강조의 수명 상태 · 새 이동은 이전 타이머를 취소
150
- let highlightedHeading: HTMLElement | null = null;
151
- let highlightTimer = 0;
152
- let highlightScroll: AbortController | null = null;
153
-
154
- /**
155
- * 강조 해제와 도착 감시 정리 · 다음 앵커의 오래된 타이머 개입 방지
156
- */
157
- const clearHeadingHighlight = () => {
158
- clearTimeout(highlightTimer);
159
- highlightScroll?.abort();
160
- highlightScroll = null;
161
- highlightedHeading?.classList.remove("wg_prose__headingText--highlighted");
162
- highlightedHeading = null;
163
- };
164
-
165
- /**
166
- * 도착한 제목만 잠시 강조 · 같은 대상 재클릭도 유지 시간 재시작
167
- */
168
- const highlightHeading = (target: HTMLElement | null) => {
169
- clearHeadingHighlight();
170
- highlightedHeading = target === null ? null : target.querySelector<HTMLElement>(".wg_prose__headingText");
171
-
172
- if (highlightedHeading === null) {
173
- return;
174
- }
175
-
176
- highlightedHeading.classList.add("wg_prose__headingText--highlighted");
177
- highlightTimer = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
178
- highlightScroll = new AbortController();
179
-
180
- // 부드러운 스크롤이 끝난 뒤에도 3초 확보 · 움직이지 않거나 scrollend가 없으면 위 타이머 사용
181
- document.addEventListener("scrollend", handleHighlightArrival, {signal: highlightScroll.signal});
182
- };
183
-
184
- /**
185
- * 본문 스크롤의 첫 도착에서 유지 시간 갱신 · 사이드바 스크롤은 제외
186
- */
187
- const handleHighlightArrival = (event: Event) => {
188
- if (event.target !== document) {
189
- return;
190
- }
191
-
192
- clearTimeout(highlightTimer);
193
- highlightScroll?.abort();
194
- highlightScroll = null;
195
- highlightTimer = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
196
- };
197
-
198
- /**
199
- * 같은 hash를 다시 눌러도 접힌 대상 공개 · 기본 링크 이동 유지
200
- */
201
- const handleHashClick = (event: MouseEvent) => {
202
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
203
- return;
204
- }
205
-
206
- if (
207
- event.currentTarget instanceof HTMLAnchorElement &&
208
- (event.currentTarget.target === "" || event.currentTarget.target === "_self") &&
209
- !event.currentTarget.hasAttribute("download") &&
210
- event.currentTarget.origin === location.origin &&
211
- event.currentTarget.pathname === location.pathname &&
212
- event.currentTarget.search === location.search &&
213
- event.currentTarget.hash
214
- ) {
215
- const target = revealHashTarget(event.currentTarget.hash);
216
-
217
- if (event.currentTarget.hash === location.hash) {
218
- highlightHeading(target);
219
- }
220
- }
221
- };
222
-
223
- /**
224
- * hash 변경의 숨은 대상 공개와 위치 보정
225
- */
226
- const handleHashChange = () => {
227
- const target = revealHashTarget(location.hash);
228
-
229
- target?.scrollIntoView();
230
- highlightHeading(target);
231
- };
232
-
233
- for (const link of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
234
- link.addEventListener("click", handleHashClick);
235
- }
236
-
237
- addEventListener("hashchange", handleHashChange);
238
-
239
- // 목차 링크와 실제 제목 연결
240
- const tocSections = [...document.querySelectorAll<HTMLAnchorElement>("[data-toc-link]")].flatMap((link) => {
241
- const heading = findHashTarget(link.hash);
242
- const item = link.parentElement;
243
-
244
- if (!heading || !item) {
245
- return [];
246
- }
247
-
248
- return [
249
- {
250
- heading,
251
- link,
252
- item,
253
- list: item.parentElement,
254
- subs: [...item.querySelectorAll<HTMLAnchorElement>("[data-toc-sub-link]")].flatMap((sub) => {
255
- const subHeading = findHashTarget(sub.hash);
256
- const subItem = sub.parentElement;
257
-
258
- return subHeading && subItem ? [{heading: subHeading, link: sub, item: subItem}] : [];
259
- }),
260
- },
261
- ];
262
- });
263
- const firstSection = tocSections.at(0);
264
-
265
- if (firstSection) {
266
- /**
267
- * 읽는 선 기준 절·소제목 표시
268
- * 기준: 제목의 scroll-margin-top + reading_line_slack_px
269
- */
270
- const markActive = () => {
271
- // scroll-margin-top 계산값: CSS px
272
- const line = Number.parseFloat(getComputedStyle(firstSection.heading).scrollMarginTop) + reading_line_slack_px;
273
- // 접힌 Details 안 제목은 좌표가 남아도 현재 위치에서 제외
274
- const current = tocSections.findLast((section) => section.heading.checkVisibility() && section.heading.getBoundingClientRect().top <= line) ?? firstSection;
275
- const sub = current.subs.findLast((item) => item.heading.checkVisibility() && item.heading.getBoundingClientRect().top <= line);
276
- const currentIndex = tocSections.indexOf(current);
277
- const currentSubIndex = sub === undefined ? -1 : current.subs.indexOf(sub);
278
-
279
- for (const [index, section] of tocSections.entries()) {
280
- const active = section === current && sub === undefined;
281
- section.link.classList.toggle("wg_shellToc__link--active", active);
282
- if (active) {
283
- section.link.setAttribute("aria-current", "location");
284
- } else {
285
- section.link.removeAttribute("aria-current");
286
- }
287
- section.item.classList.toggle("wg_shellToc__item--trail", section.list === current.list && index < currentIndex);
288
- section.item.classList.toggle("wg_shellToc__item--current", section === current);
289
-
290
- for (const [subIndex, item] of section.subs.entries()) {
291
- item.link.classList.toggle("wg_shellToc__subLink--active", item === sub);
292
- if (item === sub) {
293
- item.link.setAttribute("aria-current", "location");
294
- } else {
295
- item.link.removeAttribute("aria-current");
296
- }
297
- item.item.classList.toggle("wg_shellToc__subItem--trail", section === current && subIndex < currentSubIndex);
298
- item.item.classList.toggle("wg_shellToc__subItem--current", item === sub);
299
- }
300
- }
301
- };
302
-
303
- addEventListener("scroll", markActive, {passive: true});
304
- addEventListener("resize", markActive);
305
- addEventListener("toggle", markActive, true);
306
- markActive();
307
- }
308
-
309
- const userScroll = new AbortController();
310
-
311
- for (const type of ["wheel", "touchmove", "keydown", "mousedown"]) {
312
- addEventListener(type, () => userScroll.abort(), {once: true, passive: true});
313
- }
314
-
315
- /**
316
- * 글꼴 로드 후 hash 위치 보정 · 사용자 입력 시 자동 이동 취소
317
- * 초기 smooth scroll과 보정의 충돌 방지 · 보정 후 CSS 동작 복원
318
- */
319
- const settleHashScroll = async () => {
320
- await document.fonts.ready;
321
-
322
- if (hashTarget && !userScroll.signal.aborted) {
323
- hashTarget.scrollIntoView({behavior: "instant"});
324
- highlightHeading(hashTarget);
325
- }
326
-
327
- document.documentElement.style.removeProperty("scroll-behavior");
328
- };
329
-
330
- // 글꼴 요청 완료 시점 보장 · load 후 fonts.ready
331
- if (document.readyState === "complete") {
332
- settleHashScroll();
333
- } else {
334
- addEventListener("load", settleHashScroll, {once: true});
335
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * 모바일 탐색 전환 · wg-shell.css의 폭 조건과 일치
3
- */
4
- export const navigation_mobile_query = "(width < 1024px)";
5
-
6
- /**
7
- * 같은 탭에서 문서 이동 후 탐색 위치 복원 · 데스크톱과 드로어는 별도 저장
8
- */
9
- export const navigation_scroll_storage_key = "for-humanity:navigation-scroll";
@@ -1,147 +0,0 @@
1
- import {navigation_mobile_query} from "@/component/widget/shell/_constant/navigation";
2
- import {revealHashTarget} from "@/util/dom/reveal-hash-target";
3
-
4
- /**
5
- * 같은 문서 탐색을 데스크톱 사이드바와 모바일 모달 사이에서 이동
6
- * 닫기·크기 변경에서 스크롤과 포커스 복원 · 초기화 실패 시 기본 목록 유지
7
- */
8
- export const bindMobileNavigation = () => {
9
- const navigation = document.querySelector<HTMLElement>("[data-navigation]");
10
- const dialog = document.querySelector<HTMLDialogElement>("[data-navigation-drawer]");
11
- const content = document.querySelector<HTMLElement>("[data-navigation-content]");
12
- const openButton = document.querySelector<HTMLButtonElement>("[data-navigation-open]");
13
- const closeButton = document.querySelector<HTMLButtonElement>("[data-navigation-close]");
14
- const sidebar = navigation?.parentElement;
15
-
16
- if (!navigation || !dialog || !content || !openButton || !closeButton || !sidebar) {
17
- return;
18
- }
19
-
20
- const mobile = matchMedia(navigation_mobile_query);
21
-
22
- /**
23
- * Escape 등 네이티브 닫기에서도 배경 스크롤과 버튼의 상태 복원
24
- */
25
- const handleClose = () => {
26
- // 닫기 직후 다시 연 모달에 이전 close 이벤트가 개입하지 않도록 함
27
- if (dialog.open) {
28
- return;
29
- }
30
-
31
- document.body.classList.remove("wg_shell__root--drawerOpen");
32
- openButton.setAttribute("aria-expanded", "false");
33
- };
34
-
35
- /**
36
- * 기본 링크 이동보다 먼저 잠금 해제 · 네이티브 close가 열기 버튼으로 포커스 복원
37
- */
38
- const closeNavigation = () => {
39
- dialog.close();
40
- handleClose();
41
- };
42
-
43
- /**
44
- * 네이티브 모달로 외부 포커스 차단 · 모바일에서만 열기
45
- */
46
- const handleOpen = () => {
47
- if (!mobile.matches || dialog.open) {
48
- return;
49
- }
50
-
51
- dialog.showModal();
52
- document.body.classList.add("wg_shell__root--drawerOpen");
53
- openButton.setAttribute("aria-expanded", "true");
54
- };
55
-
56
- /**
57
- * 대화상자 바깥 배경 클릭만 닫기 · 내부의 빈 공간은 유지
58
- */
59
- const handleBackdropClick = (event: MouseEvent) => {
60
- const bounds = dialog.getBoundingClientRect();
61
-
62
- if (event.target === dialog && (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom)) {
63
- closeNavigation();
64
- }
65
- };
66
-
67
- /**
68
- * 링크의 기본 이동은 유지 · 목차는 닫은 뒤 실제 제목에 키보드 위치 전달
69
- */
70
- const handleNavigationClick = (event: MouseEvent) => {
71
- if (
72
- !dialog.open ||
73
- event.defaultPrevented ||
74
- event.button !== 0 ||
75
- event.metaKey ||
76
- event.ctrlKey ||
77
- event.shiftKey ||
78
- event.altKey ||
79
- !(event.target instanceof Element)
80
- ) {
81
- return;
82
- }
83
-
84
- const link = event.target.closest("a[href]");
85
-
86
- if (!(link instanceof HTMLAnchorElement) || (link.target !== "" && link.target !== "_self") || link.hasAttribute("download")) {
87
- return;
88
- }
89
-
90
- closeNavigation();
91
-
92
- if (link.origin === location.origin && link.pathname === location.pathname && link.search === location.search && link.hash) {
93
- const target = revealHashTarget(link.hash);
94
-
95
- if (target) {
96
- target.tabIndex = -1;
97
- target.focus({preventScroll: true});
98
- }
99
- }
100
- };
101
-
102
- /**
103
- * 반응형 전환에도 목록 중복 없음 · 열린 모달을 데스크톱으로 가져가기 전에 닫기
104
- */
105
- const handleLayoutChange = () => {
106
- const focused = document.activeElement;
107
-
108
- if (mobile.matches) {
109
- openButton.classList.add("wg_shell__menuToggle--visible");
110
-
111
- if (navigation.parentElement !== content) {
112
- content.append(navigation);
113
- }
114
-
115
- if (focused instanceof HTMLElement && navigation.contains(focused)) {
116
- openButton.focus({preventScroll: true});
117
- }
118
-
119
- return;
120
- }
121
-
122
- closeNavigation();
123
-
124
- // 첫 desktop 연결에서 같은 DOM을 재삽입하면 내부 스크롤이 0으로 초기화됨
125
- if (navigation.parentElement !== sidebar) {
126
- sidebar.append(navigation);
127
- }
128
-
129
- if (focused instanceof HTMLElement && navigation.contains(focused)) {
130
- focused.focus({preventScroll: true});
131
- } else if (focused === openButton || (focused instanceof HTMLElement && dialog.contains(focused))) {
132
- sidebar.querySelector<HTMLAnchorElement>(".wg_shell__brand")?.focus({preventScroll: true});
133
- }
134
-
135
- // CSS의 폭 조건이 먼저 버튼을 숨겨 포커스가 사라지지 않도록 복원 후 숨김
136
- openButton.classList.remove("wg_shell__menuToggle--visible");
137
- };
138
-
139
- openButton.addEventListener("click", handleOpen);
140
- closeButton.addEventListener("click", closeNavigation);
141
- dialog.addEventListener("close", handleClose);
142
- dialog.addEventListener("click", handleBackdropClick);
143
- navigation.addEventListener("click", handleNavigationClick);
144
- mobile.addEventListener("change", handleLayoutChange);
145
- handleLayoutChange();
146
- sidebar.classList.add("wg_shell__sidebar--navigationReady");
147
- };
@@ -1,47 +0,0 @@
1
- /**
2
- * 실제 남은 내용이 있는 위·아래 끝만 흐림 표시 · 스크롤·글꼴·화면 크기 추적
3
- * HTML 직후 직렬화해 실행하므로 import나 바깥 변수 없이 브라우저 DOM만 사용
4
- * 관찰자는 문서 수명 동안 유지 · BFCache 복귀에서도 같은 탐색 DOM 추적
5
- */
6
- export const bindNavigationOverflow = () => {
7
- const ports = [...document.querySelectorAll<HTMLElement>("[data-navigation-scroll]")];
8
- const frame = {id: 0};
9
- // 소수 픽셀 반올림으로 끝에서 흐림이 남는 것을 방지
10
- const edgeTolerance = 1;
11
-
12
- /**
13
- * 콘텐츠가 잘리는 끝만 표시 · 네이티브 스크롤은 그대로 사용
14
- */
15
- const updateOverflow = () => {
16
- frame.id = 0;
17
-
18
- for (const port of ports) {
19
- port.classList.add("wg_shell__scroll--ready");
20
- port.classList.toggle("wg_shell__scroll--above", port.scrollTop > edgeTolerance);
21
- port.classList.toggle("wg_shell__scroll--below", port.scrollHeight - port.clientHeight - port.scrollTop > edgeTolerance);
22
- }
23
- };
24
-
25
- /**
26
- * 연속 스크롤과 크기 변경은 프레임당 한 번만 반영
27
- */
28
- const scheduleOverflow = () => {
29
- if (frame.id === 0) {
30
- frame.id = requestAnimationFrame(updateOverflow);
31
- }
32
- };
33
-
34
- const observer = new ResizeObserver(scheduleOverflow);
35
-
36
- for (const port of ports) {
37
- port.addEventListener("scroll", scheduleOverflow, {passive: true});
38
- observer.observe(port);
39
-
40
- if (port.firstElementChild) {
41
- observer.observe(port.firstElementChild);
42
- }
43
- }
44
-
45
- addEventListener("pageshow", scheduleOverflow);
46
- updateOverflow();
47
- };