Skill Tracer
Skills › Archify › archify › reader.js

reader.js

666 lines · archify/explainer/reader.js at a406594

1(() => {
2 'use strict';
3 const root = document.documentElement;
4 const read = id => JSON.parse(document.getElementById(id).textContent);
5 const prepared = read('archify-explainer-prepared');
6 const diagrams = prepared.preparedDiagrams.diagrams || prepared.preparedDiagrams;
7 const words = read('explainer-reader-words');
8 const english = !root.lang.startsWith('zh');
9 const say = (en,zh) => english ? en : zh;
10 const byId = id => document.getElementById(id);
11 const all = (selector, within = document) => [...within.querySelectorAll(selector)];
12 let toastTimer;
13 function notify(text) { const toast=byId('toast');toast.textContent=text;toast.classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.classList.remove('visible'),3000); }
14 // Measured board layout. Each panel is measured at every column width, then each column template is
15 // simulated and the one with the least page height and blank area wins. Panels keep source order on
16 // a 1px row grid, so the DOM and reading order never change.
17 // Without JavaScript, or on narrow windows, the static spans from the renderer stay in force.
18 const board = document.querySelector('.board');
19 function layoutBoard() {
20 const panels = all(':scope>.panel',board);
21 const floor = textFloor();
22 // A diagram needs enough width for its smallest label to reach the reading floor.
23 const readableWidth = svg => {
24 const viewWidth = svg.viewBox.baseVal?.width || svg.getBoundingClientRect().width;
25 const smallest = Math.min(...all('text',svg).filter(text=>text.textContent.trim() && !text.closest('[data-legend]')).map(text=>(parseFloat(text.getAttribute('font-size')) || parseFloat(getComputedStyle(text).fontSize)) / secondary(text)));
26 return Number.isFinite(smallest) && smallest > 0 ? viewWidth * floor / smallest : 0;
27 };
28 // Beside its steps, the diagram column is exactly that wide; without room for both, it wraps above them.
29 // It grows no wider than the preview draws the diagram, so spare width goes to the steps.
30 all('.guided').forEach(guide=>{
31 const preview = guide.querySelector('.diagram-preview'), need = Math.ceil(readableWidth(preview.querySelector('svg')) + 2);
32 guide.style.setProperty('--guide-figure',need + 'px');
33 guide.style.setProperty('--guide-figure-max',Math.max(need,Math.ceil(parseFloat(getComputedStyle(preview).getPropertyValue('--native-width')) * 1.25 || 0)) + 'px');
34 });
35 const reset = () => { board.classList.remove('measured'); panels.forEach(panel=>{panel.style.gridColumn = '';panel.style.gridRow = '';}); };
36 if (window.innerWidth <= 760 || panels.length < 2) { reset(); return; }
37 const gap = parseFloat(getComputedStyle(board).columnGap) || 0;
38 const column = (board.clientWidth - gap * 11) / 12;
39 const px = span => span * column + (span - 1) * gap;
40 const readableSpan = panel => {
41 const svgs = all('.diagram-preview>svg',panel);
42 if (!svgs.length) return 0;
43 const body = panel.querySelector('.panel-body'), style = getComputedStyle(body);
44 const inset = panel.offsetWidth - body.clientWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
45 const need = Math.max(...svgs.map(svg=>readableWidth(svg) + inset));
46 return [3,4,5,6,7,8,9,12].find(span=>px(span) >= need) || 12;
47 };
48 const SPANS = [4,5,6,7,8,12];
49 board.classList.add('measuring');
50 const items = panels.map(panel=>{
51 const fixed = Number(panel.dataset.spanFixed) || 0;
52 const wide = fixed >= 9 || Number((panel.className.match(/\bspan(\d+)\b/) || [])[1]) >= 9;
53 const min = Math.max(4,Number(panel.dataset.spanMin) || 4,fixed && fixed < 9 ? fixed : 0,readableSpan(panel));
54 const height = {}, fits = {};
55 for (const span of SPANS) {
56 panel.style.width = px(span) + 'px';
57 height[span] = Math.ceil(panel.getBoundingClientRect().height);
58 fits[span] = span >= min && !all('.table-scroll,.diff-scroll,pre',panel).some(element=>element.scrollWidth > element.clientWidth + 1);
59 }
60 panel.style.width = '';
61 return {panel,wide,height,fits,text:panel.dataset.measure === 'text'};
62 });
63 board.classList.remove('measuring');
64 const width = board.clientWidth;
65 const excess = (item,span) => item.text ? Math.max(0,px(span) - 720) / 720 * px(span) * item.height[span] * .6 : 0;
66 // One set of column lines per section. Panels fill the shortest fitting column in source
67 // order; a panel that fits no column takes the full width and closes the section above it. The
68 // last panel of every column stretches to the section bottom, so column edges and bottoms align.
69 function simulate(template, section, forced = []) {
70 const lines = template.reduce((acc,span)=>[...acc,acc[acc.length - 1] + span],[1]);
71 const placed = [], options = [], chosen = [];
72 let cursor = 0, waste = 0, wide = 0, heights, last, previous;
73 const open = () => { heights = template.map(()=>0); last = template.map(()=>null); previous = {top:0,c:-1,height:0}; };
74 // heights[c] is the offset of the next panel in column c, so it includes one trailing gap.
75 const close = () => {
76 // A conclusion alone in its section takes the full width rather than leaving blank columns beside it.
77 const alone = last.filter(Boolean);
78 if (alone.length === 1 && alone[0] === placed[0] && placed.length === 1 && template.length > 1) {
79 Object.assign(alone[0],{line:1,span:12,height:alone[0].item.height[12]});
80 cursor += alone[0].height + gap;
81 open();
82 return;
83 }
84 const bottom = Math.max(...heights) - gap;
85 if (bottom <= 0) return;
86 template.forEach((span,c)=>{
87 waste += px(span) * (heights[c] ? bottom - (heights[c] - gap) : bottom * 1.6);
88 if (last[c]) last[c].height = cursor + bottom - last[c].top;
89 });
90 cursor += bottom + gap;
91 open();
92 };
93 open();
94 for (const [index,item] of section.entries()) {
95 const answer = item.panel.id === 'answer' && item === items[0];
96 const choices = template.map((span,c)=>({c,span})).filter(({c,span})=>template.length > 1 && !item.wide && item.fits[span] && (!answer || c === 0));
97 options.push(choices.length);
98 if (!choices.length) {
99 chosen.push(0);
100 close();
101 placed.push({item,line:1,span:12,top:cursor,height:item.height[12]});
102 wide += excess(item,12);
103 cursor += item.height[12] + gap;
104 continue;
105 }
106 // Reading order is top to bottom, then left to right: a panel never starts above the previous
107 // one, and starts clearly lower when placed to its left. The panel above it stretches to close the gap.
108 const start = c => Math.max(heights[c], c < previous.c ? previous.top + Math.min(160, previous.height / 2) : previous.top);
109 const bottom = ({c,span}) => start(c) + item.height[span];
110 const selected = choices[forced[index]] || choices.reduce((a,b)=>bottom(b) < bottom(a) - 1 ? b : a), {c,span} = selected;
111 chosen.push(choices.indexOf(selected));
112 const top = start(c);
113 // A gap with no panel above it to stretch is a visible hole, which reads worse than room inside a card.
114 if (top > heights[c]) { waste += px(span) * (top - heights[c]) * (last[c] ? 1 : 1.6); if (last[c]) last[c].height += top - heights[c]; }
115 const entry = {item,line:lines[c],span,top:cursor + top,height:item.height[span]};
116 placed.push(entry);
117 previous = {top,c,height:item.height[span]};
118 heights[c] = top + item.height[span] + gap;
119 last[c] = entry;
120 wide += excess(item,span);
121 }
122 close();
123 return {placed,options,chosen,height:cursor,cost:cursor * width + waste + wide};
124 }
125 // Greedy placement cannot see that a short fit now forces a hole later, so each panel's column is
126 // chosen by completing the rest of the section greedily for every option and keeping the cheapest.
127 function plan(template, section) {
128 let best = simulate(template,section);
129 for (let index = 0; index < section.length; index++) {
130 const prefix = best.chosen.slice(0,index);
131 for (let option = 0; option < best.options[index]; option++) {
132 const trial = simulate(template,section,[...prefix,option]);
133 if (trial.cost < best.cost - 1) best = trial;
134 }
135 }
136 return best;
137 }
138 // Full-width panels split the page into sections; each section picks its own column lines.
139 const templates = [[6,6],[5,7],[7,5],[4,8],[8,4],[4,4,4],[12]];
140 const sections = items.reduce((list,item)=>{ if (item.wide || list.at(-1)?.[0]?.wide) list.push([item]); else list.at(-1).push(item); return list; },[[]]).filter(section=>section.length);
141 let offset = 0;
142 for (const section of sections) {
143 const layout = templates.map(template=>plan(template,section)).reduce((a,b)=>b.cost < a.cost ? b : a);
144 layout.placed.forEach(({item,line,span,top,height})=>{
145 item.panel.style.gridColumn = `${line} / span ${span}`;
146 item.panel.style.gridRow = `${Math.round(offset + top) + 1} / span ${Math.max(1,Math.round(height))}`;
147 });
148 offset += layout.height;
149 }
150 board.classList.add('measured');
151 }
152 // Phase rows that step down share one lane width, which leaves a wide empty strip beside each row.
153 // In the preview each lane frame hugs its own nodes; the full Viewer keeps the native geometry.
154 function fitLaneFrames(svg) {
155 if (svg.closest('.bare-lanes')) return;
156 const frames = all('rect[data-composition-frame-kind="lane"]',svg);
157 if (frames.length < 2) return;
158 const nodes = all('[data-node-id]',svg).map(node=>node.getBBox());
159 frames.forEach(frame=>{
160 const y = frame.y.baseVal.value, bottom = y + frame.height.baseVal.value, label = frame.nextElementSibling?.tagName === 'text' ? frame.nextElementSibling : null;
161 const inside = nodes.filter(box=>box.y >= y && box.y + box.height <= bottom);
162 if (!inside.length) return;
163 const pad = 16, left = Math.min(...inside.map(box=>box.x)) - pad, right = Math.max(...inside.map(box=>box.x + box.width)) + pad;
164 const width = Math.max(right - left, (label?.getComputedTextLength() || 0) + 28);
165 frame.setAttribute('x',left); frame.setAttribute('width',width);
166 label?.setAttribute('x',left + 14);
167 });
168 }
169 // The legend and grid are hidden in the preview, so fit the viewBox to the drawing itself.
170 function cropDiagramPreviews() {
171 all('.diagram-preview>svg').forEach(svg=>{
172 if (svg.dataset.cropped) return;
173 fitLaneFrames(svg);
174 let box; try { box = svg.getBBox(); } catch { return; }
175 if (!box.width || !box.height) return;
176 const pad = 6;
177 svg.setAttribute('viewBox',[box.x - pad,box.y - pad,box.width + pad * 2,box.height + pad * 2].map(n=>Math.round(n)).join(' '));
178 svg.parentElement.style.setProperty('--native-width',Math.round(box.width + pad * 2) + 'px');
179 svg.dataset.cropped = 'true';
180 });
181 }
182 // Edge labels are secondary to node text and may sit a little below the reading floor.
183 const secondary = text => text.closest('[data-edge-id]') ? .7 : 1;
184 function textFloor() {
185 const bodySize = parseFloat(getComputedStyle(document.querySelector('.block-prose>p') || document.body).fontSize);
186 return window.innerWidth >= 1440 ? Math.max(10,bodySize * .75) : 10;
187 }
188 // A fit notice that appears or goes away changes a panel's height after the board was planned,
189 // so the board is planned again and the notices rechecked at the new widths.
190 function ensureDiagramReadability() { if (fitDiagrams()) { layoutBoard(); fitDiagrams(); } }
191 function fitDiagrams() {
192 const minimumText = textFloor();
193 let changed = false;
194 // Only a diagram beside its steps stays in view; stacked above them, it would slide over them.
195 all('.guided').forEach(guide=>guide.classList.toggle('beside',guide.firstElementChild.getBoundingClientRect().left >= guide.lastElementChild.getBoundingClientRect().right));
196 all('.diagram-preview>svg').forEach(svg=>{
197 // Measure the current available width, rather than retaining a wider viewport's floor.
198 svg.style.minWidth = '0px';
199 const sizes = all('text',svg).filter(text=>text.textContent.trim() && text.getBoundingClientRect().width > 0).map(text=>{const matrix = text.getScreenCTM();return matrix ? parseFloat(getComputedStyle(text).fontSize) / secondary(text) * (()=>{const trace=matrix.a**2+matrix.b**2+matrix.c**2+matrix.d**2;const determinant=matrix.a*matrix.d-matrix.b*matrix.c;return Math.sqrt(Math.max(0,(trace-Math.sqrt(Math.max(0,trace**2-4*determinant**2)))/2));})() : Infinity;});
200 const smallest = Math.min(...sizes);
201 const preview = svg.parentElement;
202 const needed = svg.getBoundingClientRect().width * minimumText / smallest;
203 const widened = smallest > 0 && smallest < minimumText && needed <= preview.clientWidth;
204 if (widened) preview.style.setProperty('--native-width',Math.ceil(needed / 1.25) + 'px');
205 const insufficient = smallest > 0 && smallest < minimumText && !widened;
206 if (insufficient) {
207 preview.dataset.diagramRequiredWidth = String(Math.ceil(svg.getBoundingClientRect().width * minimumText / smallest));
208 } else delete preview.dataset.diagramRequiredWidth;
209 preview.dataset.diagramFit = insufficient ? 'insufficient' : 'fit';
210 preview.dataset.diagramMinimumText = String(minimumText);
211 const status = svg.closest('.block-diagram').querySelector('.diagram-fit-status');
212 changed ||= status.hidden === insufficient;
213 status.hidden = !insufficient;
214 status.textContent = insufficient ? say('This diagram cannot fit this page width with readable text. Open the interactive view to inspect it.','此图在当前页宽下放不下且文字会太小,请打开交互图查看。') : '';
215 });
216 return changed;
217 }
218 const preference = matchMedia('(prefers-color-scheme: dark)');
219 let theme = 'auto';
220 function applyTheme() {
221 const effective = theme === 'auto' ? preference.matches ? 'dark' : 'light' : theme;
222 root.dataset.theme = effective;
223 all('[data-theme-choice]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.themeChoice === theme)));
224 all('.diagram-preview').forEach(preview=>preview.dataset.theme = effective);
225 if (viewer?.ready) sendViewer();
226 }
227 all('[data-theme-choice]').forEach(button=>button.addEventListener('click',()=>{theme = button.dataset.themeChoice; applyTheme();}));
228 preference.addEventListener?.('change',applyTheme);
229 // Toolbar menus, not native selects, so they match the toolbar: arrow keys move, Escape or Tab
230 // closes, and a click elsewhere closes.
231 function toolMenu(container, pick, current = () => null) {
232 const toggle = container.querySelector('[aria-haspopup]'), panel = container.querySelector('[role=menu]'), items = all('[role^=menuitem]',panel);
233 const set = open => { panel.hidden = !open; toggle.setAttribute('aria-expanded',String(open)); if (open) (current() || items[0]).focus(); };
234 toggle.addEventListener('click',()=>set(panel.hidden));
235 items.forEach(item=>item.addEventListener('click',()=>{ set(false); toggle.focus(); pick(item); }));
236 panel.addEventListener('keydown',event=>{
237 const index = items.indexOf(document.activeElement);
238 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus(); }
239 else if (event.key === 'Escape' || event.key === 'Tab') { set(false); if (event.key === 'Escape') toggle.focus(); }
240 });
241 document.addEventListener('click',event=>{ if (!panel.hidden && !container.contains(event.target)) set(false); });
242 }
243 // The author picks a default style; a reader's own choice is kept for every explainer page.
244 const styleToggle = document.querySelector('[data-style-toggle]'), styleMenu = document.querySelector('.style-options');
245 const styleItems = all('[data-style-choice]',styleMenu || document);
246 const setStyle = (style, remember) => {
247 if (!styleItems.some(item=>item.dataset.styleChoice === style)) return;
248 root.dataset.style = style;
249 styleItems.forEach(item=>item.setAttribute('aria-checked',String(item.dataset.styleChoice === style)));
250 styleToggle.querySelector('[data-style-label]').textContent = words.styles[style];
251 styleToggle.querySelector('.style-swatch').dataset.swatch = style;
252 if (remember) { try { localStorage.setItem('archify-explainer-style',style); } catch {} requestAnimationFrame(()=>{ layoutBoard(); ensureDiagramReadability(); }); }
253 };
254 try { const saved = localStorage.getItem('archify-explainer-style'); if (saved) setStyle(saved,false); } catch {}
255 toolMenu(document.querySelector('.style-menu'),item=>setStyle(item.dataset.styleChoice,true),()=>styleItems.find(item=>item.getAttribute('aria-checked') === 'true'));
256 toolMenu(document.querySelector('.export-menu'),item=>item.matches('[data-print]') ? window.print() : exportImage(item.dataset.exportImage));
257 // Long image: the page as laid out now, drawn through an SVG foreignObject at twice the pixel
258 // density. Controls, dialogs and walk-through bars are left out. Browsers that refuse to export a
259 // foreignObject canvas (Safari) get a notice pointing to Print / PDF.
260 async function pageImage(format = 'png') {
261 const shell = document.querySelector('.shell'), width = document.documentElement.clientWidth, height = Math.ceil(shell.getBoundingClientRect().height + 48);
262 const clone = root.cloneNode(true);
263 clone.querySelectorAll('script,dialog,#toast,.skip-link,.tools,.ask-button,.ask-floating,.walk-bar,.walk-start,.interactive,.diagram-fit-status').forEach(element=>element.remove());
264 clone.querySelectorAll('.reader-highlight').forEach(element=>element.classList.remove('reader-highlight'));
265 clone.querySelectorAll('.focusing').forEach(element=>element.classList.remove('focusing'));
266 // Inside a foreignObject the page's <html> is not :root, so theme and style rules would drop out;
267 // the root's own attribute selects the same element with the same specificity.
268 const sheet = clone.querySelector('head>style'); if (sheet) sheet.textContent = sheet.textContent.replace(/:root\b/g,'[data-archify-artifact]');
269 clone.style.width = `${width}px`;
270 const xhtml = new XMLSerializer().serializeToString(clone);
271 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject x="0" y="0" width="${width}" height="${height}">${xhtml}</foreignObject></svg>`;
272 const image = new Image();
273 image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
274 await image.decode();
275 const scale = Math.min(2,16000 / height), canvas = document.createElement('canvas');
276 canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale);
277 const context = canvas.getContext('2d');
278 context.fillStyle = getComputedStyle(document.body).backgroundColor; context.fillRect(0,0,canvas.width,canvas.height);
279 context.drawImage(image,0,0,canvas.width,canvas.height);
280 return new Promise((resolve,reject)=>canvas.toBlob(blob=>blob ? resolve(blob) : reject(new Error('empty')),`image/${format}`,.92));
281 }
282 const exportToggle = document.querySelector('[data-export-toggle]');
283 // PNG keeps text sharpest; WebP and JPG are smaller to share. A browser that cannot encode the
284 // chosen format returns PNG, and the file is named for what it actually holds.
285 async function exportImage(format) {
286 exportToggle.disabled = true;
287 try {
288 const blob = await pageImage(format), link = document.createElement('a'), extension = {'image/jpeg':'jpg','image/webp':'webp'}[blob.type] || 'png';
289 if (blob.type !== `image/${format}`) notify(say(`This browser cannot save ${format.toUpperCase()}; saved as PNG.`,`当前浏览器不支持导出 ${format.toUpperCase()},已改存为 PNG。`));
290 link.href = URL.createObjectURL(blob); link.download = `${(document.querySelector('h1')?.textContent || 'explainer').trim().replace(/[\\/:*?"<>|]+/g,' ').slice(0,80)}.${extension}`;
291 document.body.append(link); link.click(); link.remove(); setTimeout(()=>URL.revokeObjectURL(link.href),4000);
292 } catch { notify(say('This browser cannot export the page as an image. Use Print / PDF instead.','当前浏览器无法导出长图,请用"打印 / PDF"。')); }
293 finally { exportToggle.disabled = false; }
294 }
295 window.ArchifyExplainer = {pageImage};
296 // Only the isolated srcdoc copy receives this bridge. The payload remains byte-for-byte native.
297 const viewerDialog = byId('diagram-dialog');
298 let viewer = null;
299 let viewerOpener;
300 function validHash(diagram,hash) {
301 if(!hash) return true;
302 const nodes = new Set((diagram.semantic?.nodes || []).map(node=>node.id));
303 try {
304 if(hash.startsWith('focus=')) return nodes.has(decodeURIComponent(hash.slice(6)));
305 if(hash.startsWith('route=')) { const parts = hash.slice(6).split('~').map(decodeURIComponent); return parts.length === 2 && parts.every(id=>nodes.has(id)); }
306 } catch {}
307 return false;
308 }
309 // The Viewer follows the page's theme, and its editorial preset matches the editorial style.
310 function sendViewer() { if(!viewer?.ready) return; viewer.frame.contentWindow.postMessage({kind:'archify-explainer-host',token:viewer.token,theme:root.dataset.theme,preset:root.dataset.style === 'editorial' ? 'editorial' : 'classic',hash:viewer.hash,nodes:viewer.nodes,edges:viewer.edges},'*'); }
311 function openViewer(id,hash,opener) {
312 const diagram = diagrams[id];
313 if(!diagram || !validHash(diagram,hash)) { notify(say('Diagram target unavailable.','图中目标不可用。')); return; }
314 let requestedEdges = []; try { requestedEdges = JSON.parse(opener.dataset.diagramEdges || '[]'); } catch {}
315 requestedEdges = requestedEdges.filter(id=>(diagram.semantic?.edges || []).some(edge=>edge.id === id));
316 const selectedEdges = (diagram.semantic?.edges || []).filter(edge=>requestedEdges.includes(edge.id));
317 const requestedNodes = [...new Set(hash.startsWith('focus=') ? [decodeURIComponent(hash.slice(6))] : hash.startsWith('route=') ? hash.slice(6).split('~').map(decodeURIComponent) : selectedEdges.flatMap(edge=>[edge.from,edge.to]))];
318 viewerOpener = opener;
319 byId('diagram-dialog-title').textContent = byId(id).querySelector('.diagram-title').textContent;
320 viewerDialog.showModal(); document.body.classList.add('dialog-open');
321 if(viewer?.id === id) { viewer.hash = hash; viewer.nodes = requestedNodes; viewer.edges = requestedEdges; sendViewer(); return; }
322 viewer?.frame.remove();
323 const token = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-');
324 viewerDialog.dataset.nativeReady = 'false'; delete viewerDialog.dataset.nativeFocus;
325 const frame = document.createElement('iframe'); frame.addEventListener('load',()=>{viewerDialog.dataset.nativeLoaded = id;}); frame.title = byId('diagram-dialog-title').textContent;
326 frame.setAttribute('sandbox','allow-scripts allow-downloads allow-modals');
327 const script = `<script>(()=>{
328 const token=${JSON.stringify(token)};
329 let pending=null;
330 const settle=data=>requestAnimationFrame(()=>{
331 if(pending!==data)return;
332 if(Array.isArray(data.edges)&&data.edges.length&&Array.isArray(data.nodes)){
333 if(window.Archify?.focus?.setMany)window.Archify.focus.setMany(data.nodes,{toggle:false,mode:'selection',updateUrl:false});
334 document.querySelectorAll('[data-edge-id]').forEach(edge=>{if(data.edges.includes(edge.dataset.edgeId))edge.setAttribute('data-focus-match','');else edge.removeAttribute('data-focus-match');});
335 }
336 requestAnimationFrame(()=>requestAnimationFrame(()=>{
337 if(pending!==data)return;
338 pending=null;
339 const active=document.querySelector('svg[data-focus-active]');
340 const selected=Array.from(document.querySelectorAll('[data-focus-selected]')).map(node=>node.dataset.nodeId);
341 const matched=Array.from(document.querySelectorAll('[data-edge-id][data-focus-match]')).map(edge=>edge.dataset.edgeId);
342 parent.postMessage({kind:'archify-explainer-focus-result',token,nodes:[...new Set(selected)],edges:[...new Set(matched)],active:active?.dataset.focusActive||''},'*');
343 }));
344 });
345 // The native hash listeners restore focus before the explicit host selection.
346 addEventListener('hashchange',()=>{if(pending&&location.hash.slice(1)===pending.hash)settle(pending);});
347 addEventListener('message',event=>{
348 const data=event.data;
349 if(event.source!==parent||data?.kind!=='archify-explainer-host'||data.token!==token)return;
350 if(['light','dark'].includes(data.theme))document.documentElement.dataset.theme=data.theme;
351 if(['classic','editorial'].includes(data.preset))window.Archify?.preset?.apply?.(data.preset);
352 if(typeof data.hash!=='string'||!/^(?:|focus=[^&]+|route=[^&]+~[^&]+)$/.test(data.hash))return;
353 pending=data;
354 if(location.hash.slice(1)===data.hash)dispatchEvent(new HashChangeEvent('hashchange'));
355 else location.hash=data.hash;
356 });
357 addEventListener('keydown',event=>{if(event.key==='Escape')parent.postMessage({kind:'archify-explainer-escape',token},'*');},true);
358 const ready=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>parent.postMessage({kind:'archify-explainer-ready',token},'*')));
359 if(document.readyState==='complete')ready();else addEventListener('load',ready,{once:true});
360 })();<\/script>`;
361 viewer = {id,frame,token,hash,nodes:requestedNodes,edges:requestedEdges,ready:false};
362 viewerDialog.querySelector('.dialog-loading').hidden = false;
363 viewerDialog.append(frame);
364 nativeHtml(diagram).then(html=>{
365 if (viewer?.frame !== frame) return;
366 const end = html.toLowerCase().lastIndexOf('</body>');
367 frame.srcdoc = end < 0 ? html + script : html.slice(0,end) + script + html.slice(end);
368 },()=>notify(say('This diagram could not be opened.','无法打开此交互图。')));
369 }
370 // The page stores each native Viewer gzip-compressed; inflate it once, on first open.
371 function nativeHtml(diagram) {
372 if (typeof diagram.html === 'string') return Promise.resolve(diagram.html);
373 const bytes = Uint8Array.from(atob(diagram.htmlGzip),character=>character.charCodeAt(0));
374 return diagram.inflating ||= new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
375 }
376 function closeViewer() { if(viewerDialog.open) viewerDialog.close(); }
377 document.querySelector('[data-close-diagram]').addEventListener('click',closeViewer);
378 viewerDialog.addEventListener('close',()=>{ document.body.classList.remove('dialog-open'); viewerOpener?.focus({preventScroll:true}); });
379 window.addEventListener('message',event=>{ if(!viewer || event.source !== viewer.frame.contentWindow || event.data?.token !== viewer.token) return; if(event.data.kind === 'archify-explainer-ready') {viewer.ready = true; viewerDialog.dataset.nativeReady = 'true'; viewerDialog.querySelector('.dialog-loading').hidden = true; sendViewer();} else if(event.data.kind === 'archify-explainer-focus-result') {viewerDialog.dataset.nativeFocus = JSON.stringify({nodes:event.data.nodes,edges:event.data.edges,active:event.data.active});} else if(event.data.kind === 'archify-explainer-escape') closeViewer(); });
380 all('[data-open-diagram]').forEach(button=>button.addEventListener('click',()=>openViewer(button.dataset.openDiagram,'',button)));
381 // A node clicked in the preview opens the interactive view focused on it, with its links.
382 all('.diagram-preview').forEach(preview=>preview.addEventListener('click',event=>{
383 const node = event.target.closest('[data-node-id]')?.dataset.nodeId, block = preview.closest('.block');
384 if (node) openViewer(block.id,`focus=${encodeURIComponent(node)}`,block.querySelector('[data-open-diagram]'));
385 }));
386 // One spotlight for every text-to-diagram link: the named nodes and edges stay lit, the rest dims.
387 const previewOf = id => byId(id)?.querySelector('.diagram-preview');
388 function spotlight(preview, nodes = [], edges = []) {
389 if (!preview) return;
390 let matched = false;
391 all('[data-node-id],[data-edge-id]',preview).forEach(element=>{const hit = nodes.includes(element.dataset.nodeId) || edges.includes(element.dataset.edgeId); matched ||= hit; element.classList.toggle('reader-highlight',hit);});
392 preview.classList.toggle('focusing',matched);
393 }
394 const targetOf = link => {
395 const hash = link.dataset.diagramHash || '';
396 let edges = []; try { edges = JSON.parse(link.dataset.diagramEdges || '[]'); } catch {}
397 return {preview:previewOf(link.dataset.diagramLink),nodes:hash.startsWith('focus=') ? [decodeURIComponent(hash.slice(6))] : hash.startsWith('route=') ? hash.slice(6).split('~').map(decodeURIComponent) : [],edges};
398 };
399 all('[data-diagram-link]').forEach(link=>{
400 link.addEventListener('click',event=>{ event.preventDefault(); openViewer(link.dataset.diagramLink,link.dataset.diagramHash || '',link); });
401 // A walk-through owns the highlight while it runs.
402 const highlight = active => { if (walking) return; const {preview,nodes,edges} = targetOf(link); spotlight(preview,active ? nodes : [],active ? edges : []); };
403 // A step lights its part of the diagram from anywhere in it, its description included.
404 const hover = link.closest('.steps>li') || link;
405 hover.addEventListener('mouseenter',()=>highlight(true)); hover.addEventListener('mouseleave',()=>highlight(false)); link.addEventListener('focus',()=>highlight(true)); link.addEventListener('blur',()=>highlight(false));
406 });
407 // A name in the text that is also a node label is linked to that node, once per panel, both ways:
408 // hovering the word lights the node, hovering the node lights the word.
409 function linkTerms() {
410 const terms = new Map();
411 all('.diagram-preview').forEach(preview=>all('[data-node-id][data-node-label]',preview).forEach(node=>{
412 const label = node.dataset.nodeLabel.trim();
413 const target = {diagram:preview.closest('.block').id,node:node.dataset.nodeId};
414 if ((/\p{Script=Han}/u.test(label) ? label.length >= 2 : label.length >= 3) && !terms.has(label.toLowerCase())) terms.set(label.toLowerCase(),target);
415 // A plain-word ID ("server" for "Server :443") also names the node in English prose.
416 if (/^[a-z]{3,}$/.test(target.node) && !terms.has(target.node)) terms.set(target.node,target);
417 }));
418 if (!terms.size) return;
419 const pattern = new RegExp([...terms.keys()].sort((a,b)=>b.length - a.length).map(label=>label.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'))
420 .join('|'),'giu');
421 const word = /[\p{L}\p{N}_]/u;
422 all('.panel').forEach(panel=>{
423 const seen = new Set();
424 const walker = document.createTreeWalker(panel.querySelector('.panel-body') || panel,NodeFilter.SHOW_TEXT,{acceptNode:text=>text.parentElement.closest('.block-diagram,.block-chart,pre,code,a,button,svg,figcaption,.term,.diagram-semantics,thead') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT});
425 const texts = []; while (walker.nextNode()) texts.push(walker.currentNode);
426 for (const text of texts) {
427 const value = text.nodeValue, parts = []; let last = 0;
428 for (const match of value.matchAll(pattern)) {
429 const before = value[match.index - 1], after = value[match.index + match[0].length];
430 if (seen.has(match[0].toLowerCase()) || (!/\p{Script=Han}/u.test(match[0]) && ((before && word.test(before)) || (after && word.test(after))))) continue;
431 seen.add(match[0].toLowerCase()); parts.push(value.slice(last,match.index),match[0]); last = match.index + match[0].length;
432 }
433 if (!parts.length) continue;
434 const fragment = document.createDocumentFragment();
435 parts.forEach((part,index)=>{ if (index % 2 === 0) { fragment.append(part); return; } const target = terms.get(part.toLowerCase()), span = document.createElement('span'); span.className = 'term'; span.dataset.diagram = target.diagram; span.dataset.node = target.node; span.textContent = part; fragment.append(span); });
436 fragment.append(value.slice(last)); text.replaceWith(fragment);
437 }
438 });
439 all('.term').forEach(term=>{ const lit = on => spotlight(previewOf(term.dataset.diagram),on ? [term.dataset.node] : []); term.addEventListener('mouseenter',()=>lit(true)); term.addEventListener('mouseleave',()=>lit(false)); });
440 all('.diagram-preview').forEach(preview=>{
441 const id = preview.closest('.block').id;
442 preview.addEventListener('mouseover',event=>{ const node = event.target.closest('[data-node-id]')?.dataset.nodeId; all('.term').forEach(term=>term.classList.toggle('term-lit',term.dataset.diagram === id && term.dataset.node === node)); });
443 preview.addEventListener('mouseleave',()=>all('.term-lit').forEach(term=>term.classList.remove('term-lit')));
444 });
445 }
446 // Walk-through: step lists that point into a diagram, and sequence diagrams message by message.
447 // Each step lights its part of the diagram; arrow keys move, Escape ends.
448 let walking = null;
449 function walk(control, items, show) {
450 const stop = () => { walking?.end(); };
451 if (walking?.control === control) { stop(); return; }
452 stop();
453 let index = 0;
454 const bar = document.createElement('div'); bar.className = 'walk-bar';
455 bar.innerHTML = `<button type="button" data-walk="prev">← ${words.prev}</button><span class="walk-count"></span><button type="button" data-walk="next">${words.next} →</button><span class="walk-text" aria-live="polite"></span><button type="button" data-walk="end">${words.end}</button>`;
456 // A diagram's bar sits above the diagram, so it stays in view while a tall diagram scrolls past.
457 const figure = control.closest('figcaption')?.closest('figure'); if (figure) figure.before(bar); else control.after(bar);
458 // The bar takes the start button's place while the walk runs, so one set of controls shows.
459 const hadFocus = document.activeElement === control;
460 control.setAttribute('aria-pressed','true'); control.hidden = true;
461 if (hadFocus) bar.querySelector('[data-walk=next]').focus();
462 // The bar changes the panel height the board was planned with.
463 const replan = () => requestAnimationFrame(()=>{ layoutBoard(); ensureDiagramReadability(); });
464 replan();
465 const render = () => { bar.querySelector('.walk-count').textContent = `${index + 1} / ${items.length}`; bar.querySelector('.walk-text').textContent = show(index) || ''; };
466 const move = delta => { index = Math.max(0,Math.min(items.length - 1,index + delta)); render(); };
467 bar.addEventListener('click',event=>{ const action = event.target.closest('[data-walk]')?.dataset.walk; if (action === 'end') stop(); else if (action) move(action === 'next' ? 1 : -1); });
468 const keys = event => { if (event.target.closest('input,textarea') || viewerDialog.open) return; if (event.key === 'ArrowRight') { event.preventDefault(); move(1); } else if (event.key === 'ArrowLeft') { event.preventDefault(); move(-1); } else if (event.key === 'Escape') stop(); };
469 document.addEventListener('keydown',keys);
470 walking = {control,end:()=>{ const refocus = bar.contains(document.activeElement); document.removeEventListener('keydown',keys); bar.remove(); control.hidden = false; control.setAttribute('aria-pressed','false'); if (refocus) control.focus(); show(-1); walking = null; replan(); }};
471 render();
472 }
473 function setupWalks() {
474 all('ol.steps').forEach(list=>{
475 const steps = all(':scope>li',list).filter(item=>item.querySelector('[data-diagram-link]'));
476 if (steps.length < 2) return;
477 const button = document.createElement('button'); button.type = 'button'; button.className = 'walk-start interactive'; button.textContent = `▶ ${words.walk}`; button.setAttribute('aria-pressed','false');
478 list.before(button);
479 button.addEventListener('click',()=>walk(button,steps,index=>{
480 steps.forEach((step,i)=>step.classList.toggle('walk-current',i === index));
481 all('.diagram-preview').forEach(preview=>spotlight(preview));
482 if (index < 0) return;
483 const {preview,nodes,edges} = targetOf(steps[index].querySelector('[data-diagram-link]'));
484 spotlight(preview,nodes,edges);
485 const box = preview?.getBoundingClientRect();
486 if (box && (box.bottom < 0 || box.top > innerHeight)) preview.scrollIntoView({block:'nearest',behavior:'smooth'});
487 }));
488 });
489 // A workflow no step list walks through can be walked node by node, in the order it was written,
490 // each node lit with the edges that lead into it.
491 const walked = new Set(all('ol.steps [data-diagram-link]').map(link=>link.dataset.diagramLink));
492 all('.block-diagram').forEach(block=>{
493 const diagram = diagrams[block.id], nodes = diagram?.semantic?.nodes || [], edges = diagram?.semantic?.edges || [];
494 if (diagram?.diagramType !== 'workflow' || walked.has(block.id) || nodes.length < 3) return;
495 const preview = block.querySelector('.diagram-preview');
496 const button = document.createElement('button'); button.type = 'button'; button.className = 'walk-start'; button.textContent = `▶ ${words.walkFlow}`; button.setAttribute('aria-pressed','false');
497 block.querySelector('.diagram-title')?.after(button);
498 button.addEventListener('click',()=>walk(button,nodes,index=>{
499 if (index < 0) { spotlight(preview); return; }
500 const node = nodes[index], into = edges.filter(edge=>edge.to === node.id && (index === 0 || nodes.slice(0,index).some(seen=>seen.id === edge.from)));
501 spotlight(preview,[node.id,...into.map(edge=>edge.from)],into.map(edge=>edge.id));
502 const target = preview.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`), box = target?.getBoundingClientRect();
503 if (box && (box.top < 0 || box.bottom > innerHeight - 80)) target.scrollIntoView({block:'center',behavior:'smooth'});
504 return node.label || node.id;
505 }));
506 });
507 all('.block-diagram').forEach(block=>{
508 if (diagrams[block.id]?.diagramType !== 'sequence') return;
509 const preview = block.querySelector('.diagram-preview'), messages = all('[data-edge-id]',preview);
510 if (messages.length < 2) return;
511 const button = document.createElement('button'); button.type = 'button'; button.className = 'walk-start'; button.textContent = `▶ ${words.walkMessages}`; button.setAttribute('aria-pressed','false');
512 block.querySelector('.diagram-title')?.after(button);
513 const label = id => preview.querySelector(`[data-node-id="${CSS.escape(id)}"]`)?.dataset.nodeLabel || id;
514 button.addEventListener('click',()=>walk(button,messages,index=>{
515 if (index < 0) { spotlight(preview); return; }
516 const message = messages[index];
517 spotlight(preview,[message.dataset.edgeFrom,message.dataset.edgeTo],[message.dataset.edgeId]);
518 const box = message.getBoundingClientRect();
519 if (box.top < 0 || box.bottom > innerHeight - 80) message.scrollIntoView({block:'center',behavior:'smooth'});
520 return `${label(message.dataset.edgeFrom)} → ${label(message.dataset.edgeTo)}${say(': ',':')}${message.dataset.edgeLabel || ''}`;
521 }));
522 });
523 }
524 all('[data-copy]').forEach(button=>button.addEventListener('click',()=>{
525 const text = button.closest('figure').querySelector('pre code').textContent;
526 Promise.resolve(navigator.clipboard?.writeText(text)).then(()=>{button.textContent = words.copied;setTimeout(()=>{button.textContent = words.copy;},1600);},()=>notify(say('Copy failed.','复制失败。')));
527 }));
528 // Follow-up questions: a reader asks about a panel, or about selected text in it, and copies a
529 // message that names the page, the panel ID and the quote, so the agent can revise just that panel.
530 function setupAsk() {
531 const dialog = document.createElement('dialog'); dialog.className = 'ask-dialog';
532 const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
533 dialog.innerHTML = `<form method="dialog"><header><div><h2 id="ask-title">${words.ask}</h2><p class="ask-where"></p></div><button type="button" class="ask-close" data-ask-cancel aria-label="${words.askCancel}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><blockquote hidden></blockquote><div class="ask-chips" role="group" aria-label="${words.askQuestion}">${words.askChips.map(chip=>`<button type="button" data-chip>${chip}</button>`).join('')}</div><textarea rows="4" aria-labelledby="ask-title" placeholder="${words.askPlaceholder}"></textarea><footer><p class="ask-note">${words.askNote}</p><button type="button" data-ask-cancel>${words.askCancel}</button><button type="submit" class="primary">${words.askCopy}<kbd>${mac ? '⌘↵' : 'Ctrl ↵'}</kbd></button></footer></form>`;
534 document.body.append(dialog);
535 const area = dialog.querySelector('textarea'), quoteBox = dialog.querySelector('blockquote');
536 let context = null;
537 const open = (panel, quote = '') => {
538 const number = panel.id === 'answer' ? words.answer : panel.querySelector('.panel-id')?.textContent.trim();
539 const title = panel.id === 'answer' ? words.answer : panel.dataset.title || panel.querySelector('h2')?.textContent.trim();
540 context = {panel,quote,number,title};
541 dialog.querySelector('.ask-where').textContent = panel.id === 'answer' ? title : say(`Panel ${number} · ${title}`,`面板 ${number} · ${title}`);
542 all('[data-chip]',dialog).forEach(chip=>chip.setAttribute('aria-pressed','false'));
543 quoteBox.hidden = !quote; quoteBox.textContent = quote;
544 area.value = ''; dialog.showModal(); area.focus();
545 };
546 // A suggested question is added once; choosing it again takes it back out.
547 dialog.querySelectorAll('[data-chip]').forEach(chip=>chip.addEventListener('click',()=>{
548 const lines = area.value.split('\n').filter(line=>line.trim()), on = !lines.includes(chip.textContent);
549 area.value = (on ? [...lines,chip.textContent] : lines.filter(line=>line !== chip.textContent)).join('\n');
550 chip.setAttribute('aria-pressed',String(on)); area.focus();
551 }));
552 area.addEventListener('keydown',event=>{ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); dialog.querySelector('form').requestSubmit(); } });
553 dialog.querySelectorAll('[data-ask-cancel]').forEach(button=>button.addEventListener('click',()=>dialog.close()));
554 dialog.querySelector('form').addEventListener('submit',event=>{
555 event.preventDefault();
556 const {panel,quote,number,title} = context, page = document.querySelector('h1')?.textContent.trim();
557 const file = location.protocol === 'file:' ? decodeURIComponent(location.pathname) : location.href.split('#')[0];
558 const quoteMark = text => english ? `"${text}"` : `「${text}」`;
559 const lines = [
560 `${words.askPage}${say(': ',':')}${quoteMark(page)}`,
561 `${words.askFile}${say(': ',':')}${file}`,
562 `${words.askWhere}${say(': ',':')}${panel.id === 'answer' ? title : say(`Panel ${number} ${quoteMark(title)}`,`面板 ${number}${quoteMark(title)}`)}${say(` (ID: ${panel.id})`,`(ID: ${panel.id})`)}`,
563 ...(quote ? [`${words.askQuoteLabel}${say(': ',':')}${quoteMark(quote)}`] : []),
564 `${words.askQuestion}${say(': ',':')}${area.value.trim() || words.askChips[0]}`,
565 ...(panel.id === 'answer' ? [] : [words.askPatch]),
566 ];
567 const text = lines.join('\n');
568 Promise.resolve(navigator.clipboard?.writeText(text)).then(()=>{ dialog.close(); notify(words.askCopied); },()=>{ area.value = text; area.select(); });
569 });
570 all('.panel').forEach(panel=>{
571 const button = document.createElement('button'); button.type = 'button'; button.className = 'ask-button interactive'; button.textContent = words.ask;
572 button.addEventListener('click',()=>open(panel));
573 panel.querySelector('.panel-head')?.append(button);
574 });
575 const floating = document.createElement('button'); floating.type = 'button'; floating.className = 'ask-floating'; floating.textContent = words.askQuote; floating.hidden = true;
576 document.body.append(floating);
577 let selected = null;
578 document.addEventListener('selectionchange',()=>{
579 const selection = getSelection(), text = selection.toString().replace(/\s+/g,' ').trim();
580 const panel = selection.rangeCount ? selection.getRangeAt(0).commonAncestorContainer.parentElement?.closest?.('.panel') || (selection.getRangeAt(0).commonAncestorContainer.closest?.('.panel')) : null;
581 if (!panel || text.length < 2 || dialog.open) { floating.hidden = true; selected = null; return; }
582 const box = selection.getRangeAt(0).getBoundingClientRect();
583 selected = {panel,text:text.slice(0,400)};
584 floating.style.left = `${Math.max(8,Math.min(innerWidth - 120,box.left + box.width / 2 - 45 + scrollX))}px`;
585 floating.style.top = `${box.top + scrollY - 38}px`;
586 floating.hidden = false;
587 });
588 floating.addEventListener('mousedown',event=>event.preventDefault());
589 floating.addEventListener('click',()=>{ if (selected) open(selected.panel,selected.text); floating.hidden = true; });
590 }
591 // Source cards: hovering or focusing a citation shows where the claim comes from, with the pinned
592 // code excerpt for a repository source, so a reader can check it without leaving the page.
593 const glyph = path => `<svg viewBox="0 0 16 16"><path d="${path}"/></svg>`;
594 const sourceIcons = {
595 code:glyph('M5.5 5 2.5 8l3 3M10.5 5l3 3-3 3M9 3.5 7 12.5'),
596 web:glyph('M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2zM2 8h12M8 2c1.7 1.6 2.5 3.6 2.5 6S9.7 12.4 8 14C6.3 12.4 5.5 10.4 5.5 8S6.3 3.6 8 2z'),
597 note:glyph('M4 2.5h5.5l2.5 2.5v8.5H4zM9.5 2.5V5H12M6 8h4M6 10.5h4'),
598 };
599 function setupSourceCards() {
600 const sources = prepared.preparedSources?.sources || {};
601 const card = document.createElement('div'); card.className = 'source-card'; card.hidden = true; card.setAttribute('role','tooltip');
602 document.body.append(card);
603 let hideTimer;
604 const hide = () => { hideTimer = setTimeout(()=>{ card.hidden = true; },160); };
605 card.addEventListener('mouseenter',()=>clearTimeout(hideTimer)); card.addEventListener('mouseleave',hide);
606 const show = (link, id = decodeURIComponent(link.getAttribute('href').slice('#source-'.length))) => {
607 const source = sources[id] || prepared.document?.sources?.find(entry=>entry.id === id);
608 if (!source) return;
609 clearTimeout(hideTimer);
610 const escapeText = value => String(value ?? '').replace(/[&<>"]/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[character]));
611 // A repository source shows its pinned excerpt; a web page its host; any other note its text.
612 const host = (()=>{ try { return new URL(source.url).host.replace(/^www\./,''); } catch { return ''; } })();
613 const where = source.path ? `${source.path}${source.lines ? `:${source.lines.join('–')}` : ''}` : host;
614 const kind = source.path ? 'code' : source.url ? 'web' : 'note';
615 const lines = source.path && typeof source.text === 'string' ? source.text.split('\n') : [];
616 const first = source.lines?.[0] || 1;
617 const excerpt = lines.length ? `<pre><code>${lines.slice(0,14).map((line,index)=>`<span class="line"><i>${first + index}</i>${escapeText(line) || ' '}</span>`).join('')}${lines.length > 14 ? `<span class="line more"><i></i>…</span>` : ''}</code></pre>` : '';
618 const note = !source.path && (source.text || source.method) ? `<p class="source-note">${escapeText(source.text || '')}${source.method ? `<span>${escapeText(source.method)}</span>` : ''}</p>` : '';
619 const number = /^\d+$/.test(link.textContent.trim()) ? link.textContent.trim() : '';
620 card.innerHTML = `<header><span class="source-icon" data-kind="${kind}" aria-hidden="true">${sourceIcons[kind]}</span><span class="source-head"><b>${escapeText(source.title || source.path || id)}</b>${where ? `<span class="source-where">${escapeText(where)}</span>` : ''}</span>${number ? `<span class="source-number">${number}</span>` : ''}</header>${excerpt}${note}${source.revision || source.url ? `<footer>${source.revision ? `<span class="source-pin">${say('Pinned at','固定在')} <code>${escapeText(String(source.revision).slice(0,7))}</code></span>` : '<span></span>'}${source.url ? `<a href="${escapeText(source.url)}" target="_blank" rel="noopener noreferrer">${say('Open source','打开原文')} ↗</a>` : ''}</footer>` : ''}`;
621 card.hidden = false;
622 const box = link.getBoundingClientRect(), width = Math.min(520,innerWidth - 24);
623 card.style.width = `${width}px`;
624 card.style.left = `${Math.max(12,Math.min(innerWidth - width - 12,box.left - 24)) + scrollX}px`;
625 const below = box.bottom + 8 + card.offsetHeight < innerHeight;
626 card.style.top = `${(below ? box.bottom + 8 : box.top - 8 - card.offsetHeight) + scrollY}px`;
627 };
628 all('.cite a[href^="#source-"]').forEach(link=>{
629 link.addEventListener('mouseenter',()=>show(link)); link.addEventListener('focus',()=>show(link));
630 link.addEventListener('mouseleave',hide); link.addEventListener('blur',hide);
631 });
632 // A diagram node tied to a source opens the same card, so a code diagram doubles as a code tour.
633 all('.diagram-preview[data-node-sources]').forEach(preview=>{
634 let map = {}; try { map = JSON.parse(preview.dataset.nodeSources); } catch {}
635 all('[data-node-id]',preview).forEach(node=>{
636 const id = map[node.dataset.nodeId]; if (!id) return;
637 node.classList.add('has-source');
638 node.addEventListener('mouseenter',()=>show(node,id)); node.addEventListener('mouseleave',hide);
639 });
640 });
641 document.addEventListener('keydown',event=>{ if (event.key === 'Escape') card.hidden = true; });
642 }
643 // Checklist ticks are the reader's own progress; keep them per page and block across reloads.
644 all('.checklist').forEach(list=>{
645 const key = `archify-checklist:${root.dataset.artifact}:${list.closest('.block').id}`, boxes = all('input',list);
646 let saved = []; try { saved = JSON.parse(localStorage.getItem(key) || '[]'); } catch {}
647 boxes.forEach((box,index)=>{ box.checked = saved[index] === 1; box.addEventListener('change',()=>{ try { localStorage.setItem(key,JSON.stringify(boxes.map(b=>b.checked ? 1 : 0))); } catch {} }); });
648 });
649 // Native dialogs supply Escape, focus trapping and the accessible modal semantics.
650 const printDetails = new Map();
651 all('.diagram-semantics').forEach(details=>details.open = false);
652 window.addEventListener('beforeprint',()=>all('.diagram-semantics').forEach(details=>{printDetails.set(details,details.open);details.open = true;}));
653 window.addEventListener('afterprint',()=>{printDetails.forEach((open,details)=>details.open = open);printDetails.clear();});
654 root.classList.add('js');
655 applyTheme();
656 cropDiagramPreviews();
657 linkTerms();
658 setupWalks();
659 setupAsk();
660 setupSourceCards();
661 layoutBoard();
662 const relayout = () => { layoutBoard(); ensureDiagramReadability(); };
663 Promise.resolve(document.fonts?.ready).then(()=>requestAnimationFrame(()=>requestAnimationFrame(relayout)));
664 let resizeFrame;
665 window.addEventListener('resize',()=>{cancelAnimationFrame(resizeFrame);resizeFrame = requestAnimationFrame(relayout);});
666})();