@tienne/gestalt 0.102.0 → 0.103.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 (169) hide show
  1. package/README.ko.md +2 -1
  2. package/README.md +2 -1
  3. package/dist/package.json +1 -1
  4. package/dist/plugin/role-agents/video-summarizer/AGENT.md +177 -113
  5. package/dist/plugin/skills/_shared/proactive-routing.md +2 -1
  6. package/dist/plugin/skills/research/references/design.md +1 -1
  7. package/dist/plugin/skills/video-digest/SKILL.md +408 -0
  8. package/dist/plugin/skills/video-digest/templates/background.md +15 -0
  9. package/dist/plugin/skills/video-digest/templates/blog.md +16 -0
  10. package/dist/plugin/skills/video-digest/templates/comments.md +19 -0
  11. package/dist/plugin/skills/video-digest/templates/counterpoints.md +15 -0
  12. package/dist/plugin/skills/video-digest/templates/flashcards.md +15 -0
  13. package/dist/plugin/skills/video-digest/templates/interview.md +15 -0
  14. package/dist/plugin/skills/video-digest/templates/meeting.md +15 -0
  15. package/dist/plugin/skills/video-digest/templates/mindmap.md +16 -0
  16. package/dist/plugin/skills/video-digest/templates/quiz.md +16 -0
  17. package/dist/plugin/skills/video-digest/templates/quotes.md +16 -0
  18. package/dist/plugin/skills/video-digest/templates/resources.md +14 -0
  19. package/dist/plugin/skills/video-digest/templates/timestamps.md +14 -0
  20. package/dist/src/cli/commands/digest.d.ts +57 -0
  21. package/dist/src/cli/commands/digest.d.ts.map +1 -0
  22. package/dist/src/cli/commands/digest.js +368 -0
  23. package/dist/src/cli/commands/digest.js.map +1 -0
  24. package/dist/src/cli/index.d.ts.map +1 -1
  25. package/dist/src/cli/index.js +77 -0
  26. package/dist/src/cli/index.js.map +1 -1
  27. package/dist/src/digest/blocks.d.ts +1853 -0
  28. package/dist/src/digest/blocks.d.ts.map +1 -0
  29. package/dist/src/digest/blocks.js +148 -0
  30. package/dist/src/digest/blocks.js.map +1 -0
  31. package/dist/src/digest/captions.d.ts +37 -0
  32. package/dist/src/digest/captions.d.ts.map +1 -0
  33. package/dist/src/digest/captions.js +138 -0
  34. package/dist/src/digest/captions.js.map +1 -0
  35. package/dist/src/digest/comments.d.ts +113 -0
  36. package/dist/src/digest/comments.d.ts.map +1 -0
  37. package/dist/src/digest/comments.js +153 -0
  38. package/dist/src/digest/comments.js.map +1 -0
  39. package/dist/src/digest/format.d.ts +9 -0
  40. package/dist/src/digest/format.d.ts.map +1 -0
  41. package/dist/src/digest/format.js +26 -0
  42. package/dist/src/digest/format.js.map +1 -0
  43. package/dist/src/digest/frames.d.ts +26 -0
  44. package/dist/src/digest/frames.d.ts.map +1 -0
  45. package/dist/src/digest/frames.js +109 -0
  46. package/dist/src/digest/frames.js.map +1 -0
  47. package/dist/src/digest/index.d.ts +25 -0
  48. package/dist/src/digest/index.d.ts.map +1 -0
  49. package/dist/src/digest/index.js +25 -0
  50. package/dist/src/digest/index.js.map +1 -0
  51. package/dist/src/digest/ingest.d.ts +65 -0
  52. package/dist/src/digest/ingest.d.ts.map +1 -0
  53. package/dist/src/digest/ingest.js +303 -0
  54. package/dist/src/digest/ingest.js.map +1 -0
  55. package/dist/src/digest/lint-extension.d.ts +57 -0
  56. package/dist/src/digest/lint-extension.d.ts.map +1 -0
  57. package/dist/src/digest/lint-extension.js +395 -0
  58. package/dist/src/digest/lint-extension.js.map +1 -0
  59. package/dist/src/digest/lint.d.ts +40 -0
  60. package/dist/src/digest/lint.d.ts.map +1 -0
  61. package/dist/src/digest/lint.js +517 -0
  62. package/dist/src/digest/lint.js.map +1 -0
  63. package/dist/src/digest/merge.d.ts +40 -0
  64. package/dist/src/digest/merge.d.ts.map +1 -0
  65. package/dist/src/digest/merge.js +104 -0
  66. package/dist/src/digest/merge.js.map +1 -0
  67. package/dist/src/digest/ocr.d.ts +96 -0
  68. package/dist/src/digest/ocr.d.ts.map +1 -0
  69. package/dist/src/digest/ocr.js +168 -0
  70. package/dist/src/digest/ocr.js.map +1 -0
  71. package/dist/src/digest/prose.d.ts +43 -0
  72. package/dist/src/digest/prose.d.ts.map +1 -0
  73. package/dist/src/digest/prose.js +112 -0
  74. package/dist/src/digest/prose.js.map +1 -0
  75. package/dist/src/digest/render-blocks.d.ts +32 -0
  76. package/dist/src/digest/render-blocks.d.ts.map +1 -0
  77. package/dist/src/digest/render-blocks.js +292 -0
  78. package/dist/src/digest/render-blocks.js.map +1 -0
  79. package/dist/src/digest/render-html.d.ts +22 -0
  80. package/dist/src/digest/render-html.d.ts.map +1 -0
  81. package/dist/src/digest/render-html.js +466 -0
  82. package/dist/src/digest/render-html.js.map +1 -0
  83. package/dist/src/digest/render-md.d.ts +18 -0
  84. package/dist/src/digest/render-md.d.ts.map +1 -0
  85. package/dist/src/digest/render-md.js +113 -0
  86. package/dist/src/digest/render-md.js.map +1 -0
  87. package/dist/src/digest/render.d.ts +47 -0
  88. package/dist/src/digest/render.d.ts.map +1 -0
  89. package/dist/src/digest/render.js +123 -0
  90. package/dist/src/digest/render.js.map +1 -0
  91. package/dist/src/digest/reports.d.ts +26 -0
  92. package/dist/src/digest/reports.d.ts.map +1 -0
  93. package/dist/src/digest/reports.js +70 -0
  94. package/dist/src/digest/reports.js.map +1 -0
  95. package/dist/src/digest/schema-base.d.ts +31 -0
  96. package/dist/src/digest/schema-base.d.ts.map +1 -0
  97. package/dist/src/digest/schema-base.js +19 -0
  98. package/dist/src/digest/schema-base.js.map +1 -0
  99. package/dist/src/digest/schema.d.ts +5713 -0
  100. package/dist/src/digest/schema.d.ts.map +1 -0
  101. package/dist/src/digest/schema.js +229 -0
  102. package/dist/src/digest/schema.js.map +1 -0
  103. package/dist/src/digest/server.d.ts +36 -0
  104. package/dist/src/digest/server.d.ts.map +1 -0
  105. package/dist/src/digest/server.js +278 -0
  106. package/dist/src/digest/server.js.map +1 -0
  107. package/dist/src/digest/slice.d.ts +20 -0
  108. package/dist/src/digest/slice.d.ts.map +1 -0
  109. package/dist/src/digest/slice.js +48 -0
  110. package/dist/src/digest/slice.js.map +1 -0
  111. package/dist/src/digest/store.d.ts +67 -0
  112. package/dist/src/digest/store.d.ts.map +1 -0
  113. package/dist/src/digest/store.js +152 -0
  114. package/dist/src/digest/store.js.map +1 -0
  115. package/dist/src/digest/svg.d.ts +44 -0
  116. package/dist/src/digest/svg.d.ts.map +1 -0
  117. package/dist/src/digest/svg.js +264 -0
  118. package/dist/src/digest/svg.js.map +1 -0
  119. package/dist/src/digest/templates.d.ts +38 -0
  120. package/dist/src/digest/templates.d.ts.map +1 -0
  121. package/dist/src/digest/templates.js +125 -0
  122. package/dist/src/digest/templates.js.map +1 -0
  123. package/dist/src/digest/translate.d.ts +46 -0
  124. package/dist/src/digest/translate.d.ts.map +1 -0
  125. package/dist/src/digest/translate.js +50 -0
  126. package/dist/src/digest/translate.js.map +1 -0
  127. package/dist/src/digest/viewer-js.d.ts +4 -0
  128. package/dist/src/digest/viewer-js.d.ts.map +1 -0
  129. package/dist/src/digest/viewer-js.js +710 -0
  130. package/dist/src/digest/viewer-js.js.map +1 -0
  131. package/dist/src/digest/viewer-logic.d.ts +46 -0
  132. package/dist/src/digest/viewer-logic.d.ts.map +1 -0
  133. package/dist/src/digest/viewer-logic.js +89 -0
  134. package/dist/src/digest/viewer-logic.js.map +1 -0
  135. package/dist/src/digest/ytdlp.d.ts +34 -0
  136. package/dist/src/digest/ytdlp.d.ts.map +1 -0
  137. package/dist/src/digest/ytdlp.js +64 -0
  138. package/dist/src/digest/ytdlp.js.map +1 -0
  139. package/dist/src/mcp/schemas.d.ts +48 -0
  140. package/dist/src/mcp/schemas.d.ts.map +1 -1
  141. package/dist/src/mcp/schemas.js +25 -0
  142. package/dist/src/mcp/schemas.js.map +1 -1
  143. package/dist/src/mcp/server.d.ts.map +1 -1
  144. package/dist/src/mcp/server.js +7 -1
  145. package/dist/src/mcp/server.js.map +1 -1
  146. package/dist/src/mcp/tools/digest-passthrough.d.ts +3 -0
  147. package/dist/src/mcp/tools/digest-passthrough.d.ts.map +1 -0
  148. package/dist/src/mcp/tools/digest-passthrough.js +148 -0
  149. package/dist/src/mcp/tools/digest-passthrough.js.map +1 -0
  150. package/package.json +1 -1
  151. package/plugin/.codex-plugin/plugin.json +1 -1
  152. package/plugin/.mcp.json +1 -1
  153. package/plugin/mcp.json +1 -1
  154. package/plugin/role-agents/video-summarizer/AGENT.md +177 -113
  155. package/plugin/skills/_shared/proactive-routing.md +2 -1
  156. package/plugin/skills/research/references/design.md +1 -1
  157. package/plugin/skills/video-digest/SKILL.md +408 -0
  158. package/plugin/skills/video-digest/templates/background.md +15 -0
  159. package/plugin/skills/video-digest/templates/blog.md +16 -0
  160. package/plugin/skills/video-digest/templates/comments.md +19 -0
  161. package/plugin/skills/video-digest/templates/counterpoints.md +15 -0
  162. package/plugin/skills/video-digest/templates/flashcards.md +15 -0
  163. package/plugin/skills/video-digest/templates/interview.md +15 -0
  164. package/plugin/skills/video-digest/templates/meeting.md +15 -0
  165. package/plugin/skills/video-digest/templates/mindmap.md +16 -0
  166. package/plugin/skills/video-digest/templates/quiz.md +16 -0
  167. package/plugin/skills/video-digest/templates/quotes.md +16 -0
  168. package/plugin/skills/video-digest/templates/resources.md +14 -0
  169. package/plugin/skills/video-digest/templates/timestamps.md +14 -0
@@ -0,0 +1,710 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { VIEWER_LOGIC_JS } from './viewer-logic.js';
3
+ // 템플릿 리터럴 안이라 백틱과 달러 중괄호를 쓰지 않는다
4
+ export const VIEWER_JS = VIEWER_LOGIC_JS +
5
+ `
6
+ (function(){
7
+ var dataEl = document.getElementById('digest-data');
8
+ if (!dataEl) return;
9
+ var data = JSON.parse(dataEl.textContent);
10
+ var starts = data.starts;
11
+ var root = document.documentElement;
12
+ var isFile = location.protocol === 'file:';
13
+ var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
14
+ var holder = document.getElementById('player');
15
+ var slot = document.getElementById('player-slot');
16
+ var lines = document.querySelectorAll('#script-list > li');
17
+ var manualUntil = 0;
18
+
19
+ function fmt(sec) {
20
+ var s = Math.max(0, Math.floor(sec));
21
+ var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
22
+ var mm = (m < 10 ? '0' : '') + m, rr = (r < 10 ? '0' : '') + r;
23
+ return h > 0 || data.long ? h + ':' + mm + ':' + rr : mm + ':' + rr;
24
+ }
25
+ function timeLink(sec) {
26
+ var a = document.createElement(data.watchBase ? 'a' : 'span');
27
+ a.className = 'ts';
28
+ a.textContent = fmt(sec);
29
+ a.setAttribute('data-sec', String(sec));
30
+ if (data.watchBase) {
31
+ a.href = data.watchBase + '&t=' + Math.floor(sec) + 's';
32
+ a.target = '_blank';
33
+ a.rel = 'noopener';
34
+ }
35
+ return a;
36
+ }
37
+
38
+ function setupFontScale() {
39
+ var FONT_KEY = 'gestalt-digest-font';
40
+ var range = document.getElementById('font-range');
41
+ var fontOut = document.getElementById('font-value');
42
+ function setScale(v) {
43
+ v = Math.min(145, Math.max(85, Number(v) || 100));
44
+ root.style.setProperty('--scale', String(v / 100));
45
+ if (range) range.value = String(v);
46
+ if (fontOut) fontOut.textContent = v + '%';
47
+ try { localStorage.setItem(FONT_KEY, String(v)); } catch (e) {}
48
+ }
49
+ var saved = 100;
50
+ try { saved = Number(localStorage.getItem(FONT_KEY)) || 100; } catch (e) {}
51
+ setScale(saved);
52
+ if (range) range.addEventListener('input', function(){ setScale(range.value); });
53
+ }
54
+
55
+ // 플레이어 어댑터. 유튜브와 로컬 영상이 같은 꼴을 내서 따라가기 코드는 둘을 가리지 않는다
56
+ function createPlayer() {
57
+ var player = { live: false, seek: function(){}, cue: function(){}, currentTime: function(){ return null; }, state: function(){ return -1; } };
58
+ if (isFile || !holder) return player;
59
+ if (data.localVideo) return createVideoPlayer(player);
60
+ if (!data.videoId) return player;
61
+ // live는 처음부터 켜 둬야 소섹션 따라가기와 미니 플레이어가 설치된다. 화면은 준비될 때까지 썸네일을 보인다
62
+ player.live = true;
63
+ var ready = false, failed = false, yt = null, pending = null, pendingCue = null;
64
+ function fail() {
65
+ if (failed) return;
66
+ failed = true;
67
+ ready = false;
68
+ player.live = false;
69
+ holder.classList.remove('is-live', 'is-floating');
70
+ if (slot) slot.classList.remove('is-floating');
71
+ }
72
+ player.seek = function(sec){
73
+ if (!ready) { pending = sec; return; }
74
+ yt.seekTo(sec, true);
75
+ if (yt.playVideo) yt.playVideo();
76
+ };
77
+ // 스크롤로 옮길 때는 재생 상태를 바꾸지 않는다. 시작 전이나 끝난 뒤에 seekTo를 부르면 재생이 시작돼서 위치만 잡는다
78
+ player.cue = function(sec){
79
+ if (!ready) { pendingCue = sec; return; }
80
+ var state = yt.getPlayerState ? yt.getPlayerState() : -1;
81
+ if (cueAction(state) === 'seek') yt.seekTo(sec, true);
82
+ else yt.cueVideoById({ videoId: data.videoId, startSeconds: sec });
83
+ };
84
+ player.currentTime = function(){ return ready && yt.getCurrentTime ? yt.getCurrentTime() : null; };
85
+ player.state = function(){ return ready && yt.getPlayerState ? yt.getPlayerState() : -1; };
86
+ window.onYouTubeIframeAPIReady = function(){
87
+ if (failed) return;
88
+ yt = new YT.Player('player-frame', {
89
+ videoId: data.videoId,
90
+ playerVars: { playsinline: 1, rel: 0, origin: location.origin },
91
+ events: {
92
+ onReady: function(){
93
+ if (failed) return;
94
+ ready = true;
95
+ holder.classList.add('is-live');
96
+ if (pending !== null) player.seek(pending);
97
+ else if (pendingCue !== null) player.cue(pendingCue);
98
+ pending = null;
99
+ pendingCue = null;
100
+ },
101
+ // 101, 150, 153은 임베드가 막힌 영상이라 썸네일과 새 탭 링크로 돌아간다
102
+ onError: function(ev){ if (ev && (ev.data === 101 || ev.data === 150 || ev.data === 153)) fail(); }
103
+ }
104
+ });
105
+ };
106
+ var api = document.createElement('script');
107
+ api.src = 'https://www.youtube.com/iframe_api';
108
+ api.onerror = fail;
109
+ document.head.appendChild(api);
110
+ // 광고 차단기나 오프라인이면 onerror 없이 멈추기도 해서 시간으로도 끊는다
111
+ setTimeout(function(){ if (!ready) fail(); }, 10000);
112
+ return player;
113
+ }
114
+
115
+ // 서버가 transcript.json에 적힌 원본 파일 하나만 같은 출처로 내준다. file://로 열면 브라우저가 로컬 파일 재생을 막아 안내문을 둔다
116
+ function createVideoPlayer(player) {
117
+ var video = document.getElementById('player-video');
118
+ if (!video) return player;
119
+ player.live = true;
120
+ holder.classList.add('is-live');
121
+ var ready = false, failed = false, pending = null, pendingCue = null;
122
+ function fail() {
123
+ if (failed) return;
124
+ failed = true;
125
+ ready = false;
126
+ player.live = false;
127
+ holder.classList.remove('is-live', 'is-floating');
128
+ if (slot) slot.classList.remove('is-floating');
129
+ }
130
+ player.seek = function(sec){
131
+ if (!ready) { pending = sec; return; }
132
+ video.currentTime = sec;
133
+ var p = video.play();
134
+ if (p && p.catch) p.catch(function(){});
135
+ };
136
+ player.cue = function(sec){
137
+ if (!ready) { pendingCue = sec; return; }
138
+ video.currentTime = sec;
139
+ };
140
+ player.currentTime = function(){ return ready ? video.currentTime : null; };
141
+ player.state = function(){ return ready ? videoState(video.ended, video.paused, video.currentTime, video.readyState) : -1; };
142
+ video.addEventListener('loadedmetadata', function(){
143
+ if (failed) return;
144
+ ready = true;
145
+ if (pending !== null) player.seek(pending);
146
+ else if (pendingCue !== null) player.cue(pendingCue);
147
+ pending = null;
148
+ pendingCue = null;
149
+ });
150
+ video.addEventListener('error', fail);
151
+ video.src = 'video';
152
+ return player;
153
+ }
154
+
155
+ // 스크립트 번역은 기본으로 보이고 끈 상태만 기억한다
156
+ function setupTranslation() {
157
+ var btn = document.getElementById('tr-toggle');
158
+ var box = document.querySelector('.script');
159
+ if (!btn || !box) return;
160
+ var TR_KEY = 'gestalt-digest-tr';
161
+ var on = true;
162
+ try { on = localStorage.getItem(TR_KEY) !== 'off'; } catch (e) {}
163
+ function set(v) {
164
+ on = v;
165
+ box.classList.toggle('hide-tr', !on);
166
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
167
+ }
168
+ set(on);
169
+ btn.hidden = false;
170
+ btn.addEventListener('click', function(){
171
+ set(!on);
172
+ try { localStorage.setItem(TR_KEY, on ? 'on' : 'off'); } catch (e) {}
173
+ });
174
+ }
175
+
176
+ // 파일로 열었거나 플레이어가 못 떴으면 링크 그대로 유튜브 새 탭이 열린다
177
+ function setupTimeLinks(player) {
178
+ document.addEventListener('click', function(ev){
179
+ var el = ev.target.closest ? ev.target.closest('[data-sec]') : null;
180
+ if (!el) return;
181
+ if (!player.live) {
182
+ // 도식 노드는 a가 아니라 href가 없다. 시각 링크와 같게 새 탭으로 연다
183
+ if (el.tagName.toLowerCase() === 'a' || !data.watchBase) return;
184
+ ev.preventDefault();
185
+ window.open(data.watchBase + '&t=' + Math.floor(Number(el.getAttribute('data-sec'))) + 's', '_blank', 'noopener');
186
+ return;
187
+ }
188
+ ev.preventDefault();
189
+ manualUntil = Date.now() + 5000;
190
+ player.seek(Number(el.getAttribute('data-sec')));
191
+ });
192
+ }
193
+
194
+ function setupPopups() {
195
+ var pop = document.createElement('div');
196
+ pop.id = 'digest-pop';
197
+ pop.className = 'pop';
198
+ pop.hidden = true;
199
+ pop.tabIndex = -1;
200
+ pop.setAttribute('role', 'dialog');
201
+ document.body.appendChild(pop);
202
+ document.querySelectorAll('.cite, .kw').forEach(function(b){
203
+ b.setAttribute('aria-expanded', 'false');
204
+ b.setAttribute('aria-controls', 'digest-pop');
205
+ });
206
+ var popFor = null;
207
+ function closePop(){
208
+ if (popFor) popFor.setAttribute('aria-expanded', 'false');
209
+ pop.hidden = true;
210
+ popFor = null;
211
+ }
212
+ function place(anchor, label){
213
+ if (popFor && popFor !== anchor) popFor.setAttribute('aria-expanded', 'false');
214
+ pop.setAttribute('aria-label', label);
215
+ pop.hidden = false;
216
+ var r = anchor.getBoundingClientRect();
217
+ var w = pop.offsetWidth;
218
+ var left = Math.min(Math.max(8, r.left + window.scrollX), window.scrollX + document.documentElement.clientWidth - w - 8);
219
+ pop.style.left = left + 'px';
220
+ pop.style.top = (r.bottom + window.scrollY + 6) + 'px';
221
+ popFor = anchor;
222
+ anchor.setAttribute('aria-expanded', 'true');
223
+ // 스크롤이 움직이면 소섹션 따라가기가 영상을 옮긴다
224
+ pop.focus({ preventScroll: true });
225
+ }
226
+ // 번역 줄은 같은 li 안에 있어서 원문 span만 읽는다
227
+ function lineText(i){ var li = lines[i]; var t = li && li.querySelector('.txt .src'); return t ? t.textContent : ''; }
228
+ var MAX_LINES = 8;
229
+ function openCite(btn){
230
+ var key = Number(btn.getAttribute('data-key'));
231
+ var from = Number(btn.getAttribute('data-from'));
232
+ var to = Number(btn.getAttribute('data-to'));
233
+ var win = citeWindow(from, to, MAX_LINES);
234
+ pop.textContent = '';
235
+ var head = document.createElement('div');
236
+ head.className = 'pop-head';
237
+ head.appendChild(timeLink(starts[key] || 0));
238
+ var range = document.createElement('span');
239
+ range.className = 'pop-range';
240
+ range.textContent = from === to ? ' 문장 ' + (from + 1) : ' 문장 ' + (from + 1) + '-' + (to + 1);
241
+ head.appendChild(range);
242
+ pop.appendChild(head);
243
+ var ol = document.createElement('ol');
244
+ ol.className = 'pop-lines';
245
+ for (var i = from; i <= win.end; i++) {
246
+ var li = document.createElement('li');
247
+ if (i === key) li.className = 'is-key';
248
+ li.textContent = lineText(i);
249
+ ol.appendChild(li);
250
+ }
251
+ pop.appendChild(ol);
252
+ if (win.more > 0) {
253
+ var more = document.createElement('div');
254
+ more.className = 'pop-more';
255
+ more.textContent = '외 ' + win.more + '문장';
256
+ pop.appendChild(more);
257
+ }
258
+ place(btn, '출처 툴팁');
259
+ }
260
+ function openKeyword(btn){
261
+ var box = btn.closest('.report');
262
+ var rep = box ? data.reports[Number(box.getAttribute('data-ri'))] : null;
263
+ var k = rep ? rep.keywords[btn.getAttribute('data-kw')] : null;
264
+ if (!k) return;
265
+ pop.textContent = '';
266
+ var head = document.createElement('div');
267
+ head.className = 'pop-head';
268
+ var term = document.createElement('strong');
269
+ term.textContent = k.term;
270
+ head.appendChild(term);
271
+ head.appendChild(document.createTextNode(' '));
272
+ head.appendChild(timeLink(k.sec));
273
+ pop.appendChild(head);
274
+ var p = document.createElement('p');
275
+ p.textContent = k.explanation;
276
+ pop.appendChild(p);
277
+ place(btn, '키워드 팝업');
278
+ }
279
+ document.addEventListener('click', function(ev){
280
+ var t = ev.target;
281
+ var cite = t.closest ? t.closest('.cite') : null;
282
+ var kw = t.closest ? t.closest('.kw') : null;
283
+ if (cite) { ev.preventDefault(); if (popFor === cite) closePop(); else openCite(cite); return; }
284
+ if (kw) { ev.preventDefault(); if (popFor === kw) closePop(); else openKeyword(kw); return; }
285
+ if (!pop.contains(t)) closePop();
286
+ });
287
+ // Tab으로 빠져나가면 닫는다. 트리거로 돌아가는 포커스는 클릭 토글이 처리한다
288
+ pop.addEventListener('focusout', function(ev){
289
+ var to = ev.relatedTarget;
290
+ if (pop.hidden || !to || pop.contains(to) || to === popFor) return;
291
+ closePop();
292
+ });
293
+ return {
294
+ isOpen: function(){ return !pop.hidden; },
295
+ closeAndReturn: function(){
296
+ var back = popFor;
297
+ closePop();
298
+ if (back) back.focus();
299
+ }
300
+ };
301
+ }
302
+
303
+ // 사용자가 패널을 직접 움직이면 3초 동안 멈춘다
304
+ function setupScriptFollow(player) {
305
+ if (!player.live) return;
306
+ var panel = document.getElementById('script-body');
307
+ var current = -1, holdUntil = 0;
308
+ if (panel) {
309
+ ['wheel', 'touchmove', 'keydown', 'pointerdown'].forEach(function(name){
310
+ panel.addEventListener(name, function(){ holdUntil = Date.now() + 3000; }, { passive: true });
311
+ });
312
+ }
313
+ setInterval(function(){
314
+ if (!player.live || document.hidden) return;
315
+ var t = player.currentTime();
316
+ if (t === null || t === undefined) return;
317
+ var idx = findSegmentIndex(starts, t);
318
+ if (idx === current) return;
319
+ if (lines[current]) lines[current].classList.remove('is-playing');
320
+ current = idx;
321
+ var li = lines[idx];
322
+ if (!li) return;
323
+ li.classList.add('is-playing');
324
+ if (!panel || Date.now() < holdUntil) return;
325
+ panel.scrollTo({ top: Math.max(0, li.offsetTop - panel.clientHeight / 3), behavior: reduceMotion ? 'auto' : 'smooth' });
326
+ }, 250);
327
+ }
328
+
329
+ function setupSectionMarks() {
330
+ var marks = document.querySelectorAll('[data-nav]');
331
+ if (!('IntersectionObserver' in window) || !marks.length) return;
332
+ var io = new IntersectionObserver(function(entries){
333
+ entries.forEach(function(en){
334
+ if (!en.isIntersecting) return;
335
+ var id = en.target.id;
336
+ marks.forEach(function(m){
337
+ var on = m.getAttribute('data-nav') === id;
338
+ m.classList.toggle('is-current', on);
339
+ if (on) m.setAttribute('aria-current', 'location');
340
+ else m.removeAttribute('aria-current');
341
+ });
342
+ });
343
+ }, { rootMargin: '-15% 0px -70% 0px' });
344
+ document.querySelectorAll('section.sec').forEach(function(s){ io.observe(s); });
345
+ }
346
+
347
+ // 읽는 소섹션이 바뀌면 영상을 그 구간 첫 문장으로 옮긴다. 이미 그 구간을 재생 중이면 그대로 두고 빠르게 훑을 때는 멈춘 자리만 따른다.
348
+ // 방금 시각을 눌렀으면 유예가 끝난 뒤 영상이 멈춰 있을 때만 따라간다. 기다리는 사이 그 소섹션이 화면에서 빠지면 따라가지 않는다
349
+ function setupSubsectionFollow(player) {
350
+ var subs = document.querySelectorAll('.sub[data-from]');
351
+ if (!player.live || !('IntersectionObserver' in window) || !subs.length) return;
352
+ var followTimer = null, heldTimer = null, pendingSub = null;
353
+ var currentIdx = function(){
354
+ var t = player.currentTime();
355
+ return t === null || t === undefined ? -1 : findSegmentIndex(starts, t);
356
+ };
357
+ var subIo = new IntersectionObserver(function(entries){
358
+ entries.forEach(function(en){
359
+ if (!en.isIntersecting) {
360
+ if (en.target === pendingSub) {
361
+ clearTimeout(followTimer);
362
+ clearTimeout(heldTimer);
363
+ pendingSub = null;
364
+ }
365
+ return;
366
+ }
367
+ var from = Number(en.target.getAttribute('data-from'));
368
+ var to = Number(en.target.getAttribute('data-to'));
369
+ clearTimeout(followTimer);
370
+ clearTimeout(heldTimer);
371
+ pendingSub = en.target;
372
+ followTimer = setTimeout(function(){
373
+ if (!player.live) return;
374
+ var now = Date.now();
375
+ // 시각을 누른 직후에 넘어온 소섹션은 버리지 않고 유예가 끝난 뒤 한 번만 다시 본다
376
+ if (now < manualUntil) {
377
+ heldTimer = setTimeout(function(){
378
+ if (!player.live) return;
379
+ if (!shouldFollowHeld(player.state(), currentIdx(), from, to, Date.now(), manualUntil)) return;
380
+ if (typeof starts[from] === 'number') player.cue(starts[from]);
381
+ }, manualUntil - now + 50);
382
+ return;
383
+ }
384
+ if (!shouldFollow(currentIdx(), from, to, now, manualUntil)) return;
385
+ if (typeof starts[from] === 'number') player.cue(starts[from]);
386
+ }, 400);
387
+ });
388
+ }, { rootMargin: '-15% 0px -70% 0px' });
389
+ subs.forEach(function(s){ subIo.observe(s); });
390
+ }
391
+
392
+ // 플레이어 자리가 화면 밖으로 나가면 미니 플레이어로 띄운다. iframe을 DOM에서 옮기면 영상이 처음부터 다시 로드돼서 자리는 두고 위치만 고정한다
393
+ function setupMiniPlayer(player) {
394
+ var dock = document.getElementById('player-dock');
395
+ var api = { close: function(){ return false; } };
396
+ if (!player.live || !slot || !('IntersectionObserver' in window)) return api;
397
+ var closed = false;
398
+ var setFloating = function(on){
399
+ slot.classList.toggle('is-floating', on);
400
+ holder.classList.toggle('is-floating', on);
401
+ };
402
+ new IntersectionObserver(function(entries){
403
+ if (!player.live) return;
404
+ var en = entries[entries.length - 1];
405
+ if (en.isIntersecting) { closed = false; setFloating(false); }
406
+ else if (!closed) setFloating(true);
407
+ }).observe(slot);
408
+ // 닫기 버튼이 사라지면 포커스가 body로 떨어져서 원래 자리로 옮긴다. 스크롤이 움직이면 다시 떠서 막는다
409
+ api.close = function(){
410
+ if (!holder.classList.contains('is-floating')) return false;
411
+ closed = true;
412
+ setFloating(false);
413
+ if (!slot.hasAttribute('tabindex')) slot.setAttribute('tabindex', '-1');
414
+ slot.focus({ preventScroll: true });
415
+ return true;
416
+ };
417
+ if (dock) dock.addEventListener('click', function(){ api.close(); });
418
+ return api;
419
+ }
420
+
421
+ function copyText(text, done, fail) {
422
+ if (navigator.clipboard && window.isSecureContext) {
423
+ navigator.clipboard.writeText(text).then(done, fallback);
424
+ } else fallback();
425
+ function fallback(){
426
+ var ta = document.createElement('textarea');
427
+ ta.value = text;
428
+ ta.style.position = 'fixed';
429
+ ta.style.opacity = '0';
430
+ document.body.appendChild(ta);
431
+ ta.select();
432
+ var ok = false;
433
+ try { ok = document.execCommand('copy'); } catch (e) {}
434
+ ta.remove();
435
+ (ok ? done : fail)();
436
+ }
437
+ }
438
+ var flash = makeFlash(function(){ return document.getElementById('copy-status'); });
439
+ function copyWith(btn, label, text) {
440
+ copyText(text, function(){ flash(btn, label, false); }, function(){ flash(btn, label, true); });
441
+ }
442
+
443
+ function setupCopyButton(reports, tabs) {
444
+ var copyBtn = document.getElementById('copy-md');
445
+ if (!copyBtn) return;
446
+ copyBtn.addEventListener('click', function(){
447
+ var ext = tabs.markdown();
448
+ copyWith(copyBtn, '마크다운 복사', ext !== null ? ext : data.reports[reports.current()].markdown);
449
+ });
450
+ }
451
+
452
+ // 요약 탭과 확장 탭을 오간다. 리포트 바와 미니맵은 요약 탭 안이나 body 표시로 같이 숨는다
453
+ function setupTabs() {
454
+ var TAB_KEY = 'gestalt-digest-tab:' + data.slug;
455
+ var SUMMARY = 'panel-summary';
456
+ var tabs = Array.prototype.slice.call(document.querySelectorAll('.tabs [role="tab"]'));
457
+ var current = SUMMARY;
458
+ function select(id, focus){
459
+ if (!tabs.some(function(t){ return t.getAttribute('data-tab') === id; })) id = SUMMARY;
460
+ current = id;
461
+ tabs.forEach(function(t){
462
+ var on = t.getAttribute('data-tab') === id;
463
+ t.setAttribute('aria-selected', on ? 'true' : 'false');
464
+ t.tabIndex = on ? 0 : -1;
465
+ var panel = document.getElementById(t.getAttribute('aria-controls'));
466
+ if (panel) panel.hidden = !on;
467
+ if (on && focus) t.focus();
468
+ });
469
+ if (id === SUMMARY) document.body.removeAttribute('data-tab');
470
+ else document.body.setAttribute('data-tab', 'ext');
471
+ try { localStorage.setItem(TAB_KEY, id); } catch (e) {}
472
+ }
473
+ tabs.forEach(function(t, i){
474
+ t.addEventListener('click', function(){ select(t.getAttribute('data-tab'), false); });
475
+ t.addEventListener('keydown', function(ev){
476
+ var j = tabStep(i, tabs.length, ev.key);
477
+ if (j < 0) return;
478
+ ev.preventDefault();
479
+ select(tabs[j].getAttribute('data-tab'), true);
480
+ });
481
+ });
482
+ if (tabs.length) {
483
+ // 공유된 앵커가 있으면 그 앵커가 든 탭을 연다. 저장해 둔 탭보다 먼저다
484
+ var anchor = safeDecode(location.hash.slice(1));
485
+ var target = anchor ? document.getElementById(anchor) : null;
486
+ var owner = target && target.closest ? target.closest('.ext') : null;
487
+ var saved = null;
488
+ try { saved = localStorage.getItem(TAB_KEY); } catch (e) {}
489
+ if (owner) select(owner.id, false);
490
+ else if (!target && saved) select(saved, false);
491
+ }
492
+ return {
493
+ markdown: function(){
494
+ if (current === SUMMARY) return null;
495
+ var hit = null;
496
+ (data.extensions || []).forEach(function(x){ if (x.panel === current) hit = x.markdown; });
497
+ return hit;
498
+ }
499
+ };
500
+ }
501
+
502
+ // 없는 템플릿은 뷰어가 만들지 않고 세션에 붙여 넣을 명령만 복사해 준다
503
+ function setupMore(reports) {
504
+ var btn = document.getElementById('more-btn');
505
+ var menu = document.getElementById('more-menu');
506
+ var box = document.getElementById('more-cmd-box');
507
+ var code = document.getElementById('more-cmd');
508
+ var copy = document.getElementById('more-copy');
509
+ if (!btn || !menu || !box || !code || !copy) return { close: function(){ return false; } };
510
+ function setOpen(open){
511
+ menu.hidden = !open;
512
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
513
+ }
514
+ btn.addEventListener('click', function(){ setOpen(menu.hidden); });
515
+ menu.querySelectorAll('[data-template]').forEach(function(b){
516
+ b.addEventListener('click', function(){
517
+ code.textContent = templateCommand(data.slug, b.getAttribute('data-template'), data.reports[reports.current()].lang);
518
+ box.hidden = false;
519
+ copyWith(copy, '명령 복사', code.textContent);
520
+ });
521
+ });
522
+ copy.addEventListener('click', function(){ copyWith(copy, '명령 복사', code.textContent); });
523
+ return {
524
+ close: function(){
525
+ if (menu.hidden) return false;
526
+ setOpen(false);
527
+ btn.focus();
528
+ return true;
529
+ }
530
+ };
531
+ }
532
+
533
+ // 툴바는 분량과 난이도 조합으로 리포트를 고른다. 없는 조합은 서버가 만들지 않고 생성 명령만 보여 준다
534
+ function setupReports() {
535
+ var REPORT_KEY = 'gestalt-digest-report:' + data.slug;
536
+ var boxes = document.querySelectorAll('.report[data-ri]');
537
+ var maps = document.querySelectorAll('.minimap[data-ri]');
538
+ var lengthBtns = document.querySelectorAll('.report-bar [data-length]');
539
+ var diffBtns = document.querySelectorAll('.report-bar [data-difficulty]');
540
+ var select = document.getElementById('report-select');
541
+ var missing = document.getElementById('report-missing');
542
+ var cmd = document.getElementById('report-cmd');
543
+ var copyCmd = document.getElementById('copy-cmd');
544
+ var current = 0;
545
+ function press(list, attr, value){
546
+ list.forEach(function(b){ b.setAttribute('aria-pressed', b.getAttribute(attr) === value ? 'true' : 'false'); });
547
+ }
548
+ function show(ri){
549
+ if (!data.reports[ri]) ri = 0;
550
+ current = ri;
551
+ boxes.forEach(function(b){ b.hidden = Number(b.getAttribute('data-ri')) !== ri; });
552
+ maps.forEach(function(m){ m.hidden = Number(m.getAttribute('data-ri')) !== ri; });
553
+ var r = data.reports[ri];
554
+ press(lengthBtns, 'data-length', r.length);
555
+ press(diffBtns, 'data-difficulty', r.difficulty);
556
+ if (select) select.value = String(ri);
557
+ if (missing) missing.hidden = true;
558
+ try { localStorage.setItem(REPORT_KEY, r.key); } catch (e) {}
559
+ }
560
+ function choose(length, difficulty){
561
+ var ri = pickReport(data.reports, length, difficulty);
562
+ if (ri >= 0) { show(ri); return; }
563
+ press(lengthBtns, 'data-length', length);
564
+ press(diffBtns, 'data-difficulty', difficulty);
565
+ if (cmd) cmd.textContent = reportCommand(data.slug, length, difficulty, data.reports[current].lang);
566
+ if (missing) missing.hidden = false;
567
+ }
568
+ function pressedOf(list, attr){
569
+ var v = null;
570
+ list.forEach(function(b){ if (b.getAttribute('aria-pressed') === 'true') v = b.getAttribute(attr); });
571
+ return v;
572
+ }
573
+ lengthBtns.forEach(function(b){
574
+ b.addEventListener('click', function(){
575
+ choose(b.getAttribute('data-length'), pressedOf(diffBtns, 'data-difficulty') || data.reports[current].difficulty);
576
+ });
577
+ });
578
+ diffBtns.forEach(function(b){
579
+ b.addEventListener('click', function(){
580
+ choose(pressedOf(lengthBtns, 'data-length') || data.reports[current].length, b.getAttribute('data-difficulty'));
581
+ });
582
+ });
583
+ if (select) select.addEventListener('change', function(){ show(Number(select.value)); });
584
+ if (copyCmd && cmd) copyCmd.addEventListener('click', function(){
585
+ copyWith(copyCmd, '명령 복사', cmd.textContent);
586
+ });
587
+ var saved = null;
588
+ try { saved = localStorage.getItem(REPORT_KEY); } catch (e) {}
589
+ var start = 0;
590
+ data.reports.forEach(function(r, i){ if (r.key === saved) start = i; });
591
+ // 공유된 앵커가 다른 리포트의 섹션을 가리키면 그 리포트를 연다
592
+ var anchor = safeDecode(location.hash.slice(1));
593
+ var target = anchor ? document.getElementById(anchor) : null;
594
+ var owner = target && target.closest ? target.closest('.report[data-ri]') : null;
595
+ if (owner) start = Number(owner.getAttribute('data-ri'));
596
+ if (start !== 0) show(start);
597
+ if (owner && target) target.scrollIntoView();
598
+ return { current: function(){ return current; } };
599
+ }
600
+
601
+ // 고른 답을 맞춰 보고 해설을 연다. 아무것도 안 골랐으면 정답과 해설만 연다
602
+ function setupQuiz() {
603
+ document.addEventListener('click', function(ev){
604
+ var btn = ev.target.closest ? ev.target.closest('.quiz-check') : null;
605
+ if (!btn) return;
606
+ var q = btn.closest('.quiz-q');
607
+ if (!q) return;
608
+ var answer = Number(q.getAttribute('data-answer'));
609
+ var inputs = q.querySelectorAll('input[type="radio"]');
610
+ var picked = -1;
611
+ Array.prototype.forEach.call(inputs, function(input, i){
612
+ if (input.checked) picked = i;
613
+ });
614
+ // 정답과 고른 답을 색으로만 가르면 스크린리더가 못 읽는다. 다시 누르면 앞 표시를 지우고 새로 단다
615
+ Array.prototype.forEach.call(inputs, function(input, i){
616
+ var label = input.closest('label');
617
+ if (!label) return;
618
+ Array.prototype.forEach.call(label.querySelectorAll('.quiz-mark'), function(m){ m.remove(); });
619
+ label.classList.toggle('is-answer', i === answer);
620
+ label.classList.toggle('is-picked', i === picked && i !== answer);
621
+ var mark = i === answer ? ' (정답)' : i === picked ? ' (고른 답)' : '';
622
+ if (!mark) return;
623
+ var sr = document.createElement('span');
624
+ sr.className = 'sr-only quiz-mark';
625
+ sr.textContent = mark;
626
+ label.appendChild(sr);
627
+ });
628
+ q.classList.toggle('is-right', picked === answer);
629
+ q.classList.toggle('is-wrong', picked >= 0 && picked !== answer);
630
+ var result = q.querySelector('.quiz-result');
631
+ if (result) result.textContent = picked < 0 ? '' : picked === answer ? '정답이에요' : '오답이에요';
632
+ var exp = q.querySelector('.quiz-exp');
633
+ if (exp) exp.hidden = false;
634
+ });
635
+ }
636
+
637
+ // 그림을 화면 크기로 키워 본다. 노드를 눌러 영상으로 가면 창을 닫는다
638
+ function setupZoom() {
639
+ if (!document.querySelector('[data-zoom]')) return { isOpen: function(){ return false; } };
640
+ var dialog = document.createElement('dialog');
641
+ dialog.id = 'zoom-dialog';
642
+ dialog.setAttribute('aria-label', '그림 크게 보기');
643
+ var close = document.createElement('button');
644
+ close.type = 'button';
645
+ close.className = 'zoom-btn zoom-close';
646
+ close.textContent = '닫기';
647
+ var body = document.createElement('div');
648
+ body.className = 'zoom-body';
649
+ dialog.appendChild(close);
650
+ dialog.appendChild(body);
651
+ document.body.appendChild(dialog);
652
+ var opener = null;
653
+ close.addEventListener('click', function(){ dialog.close(); });
654
+ dialog.addEventListener('click', function(ev){
655
+ if (ev.target === dialog) dialog.close();
656
+ else if (ev.target.closest && ev.target.closest('[data-sec]')) dialog.close();
657
+ });
658
+ dialog.addEventListener('close', function(){
659
+ body.textContent = '';
660
+ if (opener) opener.focus();
661
+ });
662
+ document.addEventListener('click', function(ev){
663
+ var btn = ev.target.closest ? ev.target.closest('[data-zoom]') : null;
664
+ if (!btn || typeof dialog.showModal !== 'function') return;
665
+ var fig = btn.closest('.diagram');
666
+ var svg = fig ? fig.querySelector('svg') : null;
667
+ if (!svg) return;
668
+ body.textContent = '';
669
+ body.appendChild(svg.cloneNode(true));
670
+ opener = btn;
671
+ dialog.showModal();
672
+ });
673
+ return { isOpen: function(){ return dialog.open; } };
674
+ }
675
+
676
+ setupFontScale();
677
+ setupTranslation();
678
+ // 요약 앵커로 스크롤하려면 요약 패널이 먼저 보여야 해서 탭을 리포트보다 먼저 연다
679
+ var tabs = setupTabs();
680
+ var reports = setupReports();
681
+ var more = setupMore(reports);
682
+ var player = createPlayer();
683
+ setupTimeLinks(player);
684
+ var popups = setupPopups();
685
+ setupScriptFollow(player);
686
+ setupSectionMarks();
687
+ setupSubsectionFollow(player);
688
+ var mini = setupMiniPlayer(player);
689
+ setupCopyButton(reports, tabs);
690
+ setupQuiz();
691
+ var zoom = setupZoom();
692
+ // 도식 노드는 Enter와 Space를 클릭으로 넘겨야 시각 이동과 확대 창 닫기가 같이 돈다
693
+ document.addEventListener('keydown', function(ev){
694
+ if (ev.key !== 'Enter' && ev.key !== ' ') return;
695
+ var el = ev.target.closest ? ev.target.closest('g[data-sec]') : null;
696
+ if (!el) return;
697
+ ev.preventDefault();
698
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
699
+ });
700
+ // 툴팁과 메뉴, 미니 플레이어가 함께 열려 있으면 Escape 한 번에 하나씩 닫는다
701
+ document.addEventListener('keydown', function(ev){
702
+ if (ev.key !== 'Escape' || zoom.isOpen()) return;
703
+ if (popups.isOpen()) popups.closeAndReturn();
704
+ else if (!more.close()) mini.close();
705
+ });
706
+ })();
707
+ `;
708
+ /** CSP script-src에 싣는 VIEWER_JS 해시. 렌더가 이 문자열을 그대로 인라인으로 싣는다 */
709
+ export const VIEWER_JS_SHA256 = createHash('sha256').update(VIEWER_JS, 'utf8').digest('base64');
710
+ //# sourceMappingURL=viewer-js.js.map