concept-atlas-dense-explain 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo, useRef } from 'react';
1
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
2
  import { Network, Compass, Sun, Moon, Search, X, Link as LinkIcon } from 'lucide-react';
3
3
  import { buildGraphModel } from '../model/concept-schema.js';
4
4
  import { extractConceptData } from '../model/normalize-content.js';
@@ -30,7 +30,14 @@ export function App({ mdxContent, initialData }) {
30
30
  });
31
31
 
32
32
  // Appearance state (skin x mode x component style), shared with the scroll carrier via localStorage
33
- const { skin, mode: theme, style, setSkin, setStyle, toggleMode } = useAppearance({ defaultMode: 'light' });
33
+ const {
34
+ skin,
35
+ mode: theme,
36
+ style,
37
+ setSkin,
38
+ setStyle,
39
+ toggleMode
40
+ } = useAppearance({ defaultMode: 'light' });
34
41
 
35
42
  const toggleTheme = toggleMode;
36
43
 
@@ -42,11 +49,11 @@ export function App({ mdxContent, initialData }) {
42
49
  })();
43
50
 
44
51
  const [currentNodeId, setCurrentNodeId] = useState(
45
- () => initialHashNode || graph.meta.rootId || '',
52
+ () => initialHashNode || graph.meta.rootId || ''
46
53
  );
47
54
  const [nav, setNav] = useState(() => ({
48
55
  entries: (initialHashNode ? [initialHashNode] : [graph.meta.rootId]).filter(Boolean),
49
- index: 0,
56
+ index: 0
50
57
  }));
51
58
  // Popstate handlers must read the *current* history state, not the closure
52
59
  // captured when the effect was created.
@@ -57,35 +64,64 @@ export function App({ mdxContent, initialData }) {
57
64
 
58
65
  const searchResults = useMemo(
59
66
  () => searchNodes(Array.from(graph.nodes.values()), globalQuery),
60
- [globalQuery, graph.nodes],
67
+ [globalQuery, graph.nodes]
61
68
  );
62
69
 
63
- const navigateToNode = (nodeId, { replace = false } = {}) => {
64
- if (!nodeId || !graph.nodes.has(nodeId)) return;
65
- const next = pushNode(nav, nodeId);
66
- setCurrentNodeId(nodeId);
67
- setNav(next);
68
- const nextHash = `#node=${encodeURIComponent(nodeId)}`;
69
- // Each browser entry remembers which in-app history index it maps to, so
70
- // popstate can restore the exact entry even when hashes repeat.
71
- const historyState = { atlasIndex: next.index };
72
- if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
73
- if (replace) window.history.replaceState(historyState, '', nextHash);
74
- else window.history.pushState(historyState, '', nextHash);
75
- };
76
-
77
- const moveHistory = (direction) => {
78
- // Browser history mirrors the in-app entries one-for-one (every navigation
79
- // pushState'd the matching { atlasIndex }), so step through the real
80
- // browser history. pushState here used to fork the two stacks and grow
81
- // browser history unboundedly; the popstate handler applies the result.
82
- if (!stepHistory(nav, direction)) return;
83
- window.history.go(direction);
84
- };
70
+ // Combobox state for the global search. The highlight resets whenever the
71
+ // query changes so the first arrow key lands on the first result.
72
+ const [activeResultIndex, setActiveResultIndex] = useState(-1);
73
+ const searchOpen = globalQuery.trim().length > 0 && searchResults.length > 0;
74
+ const activeResultId =
75
+ searchOpen && activeResultIndex >= 0 && searchResults[activeResultIndex]
76
+ ? `global-search-option-${searchResults[activeResultIndex].id}`
77
+ : undefined;
78
+
79
+ useEffect(() => {
80
+ setActiveResultIndex(-1);
81
+ }, [globalQuery]);
82
+
83
+ const navigateToNode = useCallback(
84
+ (nodeId, { replace = false } = {}) => {
85
+ if (!nodeId || !graph.nodes.has(nodeId)) return;
86
+ const next = pushNode(nav, nodeId);
87
+ setCurrentNodeId(nodeId);
88
+ setNav(next);
89
+ const nextHash = `#node=${encodeURIComponent(nodeId)}`;
90
+ // Each browser entry remembers which in-app history index it maps to, so
91
+ // popstate can restore the exact entry even when hashes repeat.
92
+ const historyState = { atlasIndex: next.index };
93
+ if (next === nav && !replace) return; // re-selecting the current node: don't grow browser history
94
+ if (replace) window.history.replaceState(historyState, '', nextHash);
95
+ else window.history.pushState(historyState, '', nextHash);
96
+ },
97
+ [nav, graph.nodes]
98
+ );
99
+
100
+ const selectSearchResult = useCallback(
101
+ node => {
102
+ if (!node) return;
103
+ navigateToNode(node.id);
104
+ setGlobalQuery('');
105
+ setActiveResultIndex(-1);
106
+ },
107
+ [navigateToNode]
108
+ );
109
+
110
+ const moveHistory = useCallback(
111
+ direction => {
112
+ // Browser history mirrors the in-app entries one-for-one (every navigation
113
+ // pushState'd the matching { atlasIndex }), so step through the real
114
+ // browser history. pushState here used to fork the two stacks and grow
115
+ // browser history unboundedly; the popstate handler applies the result.
116
+ if (!stepHistory(nav, direction)) return;
117
+ window.history.go(direction);
118
+ },
119
+ [nav]
120
+ );
85
121
 
86
122
  // Keyboard navigation shortcuts
87
123
  useEffect(() => {
88
- const handleKeyDown = (e) => {
124
+ const handleKeyDown = e => {
89
125
  if (e.isComposing) return;
90
126
 
91
127
  // Alt + arrows move through browsing history. Handled before the modifier
@@ -103,7 +139,8 @@ export function App({ mdxContent, initialData }) {
103
139
 
104
140
  // Toggle views with 1 and 2 or 'g' and 'e' if not focused on input.
105
141
  const target = document.activeElement;
106
- const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
142
+ const typing =
143
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
107
144
  if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
108
145
 
109
146
  const key = e.key.toLowerCase();
@@ -124,25 +161,25 @@ export function App({ mdxContent, initialData }) {
124
161
 
125
162
  window.addEventListener('keydown', handleKeyDown);
126
163
  return () => window.removeEventListener('keydown', handleKeyDown);
127
- }, [graph, currentNodeId, nav]);
164
+ }, [graph, currentNodeId, navigateToNode, moveHistory, toggleTheme]);
128
165
 
129
166
  useEffect(() => {
130
- const handlePopState = (event) => {
167
+ const handlePopState = event => {
131
168
  const current = navRef.current;
132
169
  const nodeId = readNodeFromHash();
133
170
  if (nodeId && !graph.nodes.has(nodeId)) return; // stale/foreign hash: ignore
134
- const tagged = event.state && Number.isInteger(event.state.atlasIndex)
135
- ? event.state.atlasIndex
136
- : null;
171
+ const tagged =
172
+ event.state && Number.isInteger(event.state.atlasIndex) ? event.state.atlasIndex : null;
137
173
  const taggedValid = tagged !== null && tagged >= 0 && tagged < current.entries.length;
138
174
  let next = null;
139
175
  if (nodeId) {
140
176
  // Prefer the index stamped on the entry: entries may contain the same
141
177
  // node twice, and lastIndexOf alone would resolve backwards steps to a
142
178
  // later duplicate.
143
- next = taggedValid && current.entries[tagged] === nodeId
144
- ? { entries: current.entries, index: tagged }
145
- : syncFromLocation(current, nodeId);
179
+ next =
180
+ taggedValid && current.entries[tagged] === nodeId
181
+ ? { entries: current.entries, index: tagged }
182
+ : syncFromLocation(current, nodeId);
146
183
  } else if (taggedValid) {
147
184
  // Back to the hash-less entry the page loaded on: restore it by index.
148
185
  next = { entries: current.entries, index: tagged };
@@ -190,16 +227,67 @@ export function App({ mdxContent, initialData }) {
190
227
  <input
191
228
  value={globalQuery}
192
229
  onChange={event => setGlobalQuery(event.target.value)}
193
- onKeyDown={event => { if (event.key === 'Escape') setGlobalQuery(''); }}
230
+ onKeyDown={event => {
231
+ if (event.key === 'Escape') {
232
+ setGlobalQuery('');
233
+ setActiveResultIndex(-1);
234
+ return;
235
+ }
236
+ if (!searchOpen) return;
237
+ if (event.key === 'ArrowDown') {
238
+ event.preventDefault();
239
+ setActiveResultIndex(index => (index + 1) % searchResults.length);
240
+ } else if (event.key === 'ArrowUp') {
241
+ event.preventDefault();
242
+ setActiveResultIndex(index =>
243
+ index <= 0 ? searchResults.length - 1 : index - 1
244
+ );
245
+ } else if (event.key === 'Enter' && activeResultIndex >= 0) {
246
+ event.preventDefault();
247
+ selectSearchResult(searchResults[activeResultIndex]);
248
+ }
249
+ }}
194
250
  placeholder="搜索所有节点…"
195
251
  aria-label="搜索所有节点"
252
+ role="combobox"
253
+ aria-expanded={searchOpen}
254
+ aria-controls="global-search-results"
255
+ aria-autocomplete="list"
256
+ aria-activedescendant={activeResultId}
196
257
  />
197
- {globalQuery && <button type="button" onClick={() => setGlobalQuery('')} aria-label="清除搜索"><X size={13} /></button>}
198
- {searchResults.length > 0 && (
199
- <div className="global-search-results" role="listbox">
200
- {searchResults.map(node => (
201
- <button type="button" key={node.id} onClick={() => { navigateToNode(node.id); setGlobalQuery(''); }} role="option">
202
- <span>{node.title}</span><small>{node.level}{node.kind && NODE_KINDS[node.kind] ? ` · ${NODE_KINDS[node.kind].label}` : ''} · {node.summary || node.id}</small>
258
+ {globalQuery && (
259
+ <button
260
+ type="button"
261
+ onClick={() => {
262
+ setGlobalQuery('');
263
+ setActiveResultIndex(-1);
264
+ }}
265
+ aria-label="清除搜索"
266
+ >
267
+ <X size={13} />
268
+ </button>
269
+ )}
270
+ {searchOpen && (
271
+ <div id="global-search-results" className="global-search-results" role="listbox">
272
+ {searchResults.map((node, index) => (
273
+ <button
274
+ type="button"
275
+ key={node.id}
276
+ id={`global-search-option-${node.id}`}
277
+ className={index === activeResultIndex ? 'active' : ''}
278
+ onMouseEnter={() => setActiveResultIndex(index)}
279
+ onClick={() => selectSearchResult(node)}
280
+ role="option"
281
+ aria-selected={index === activeResultIndex}
282
+ >
283
+ <span>{node.title}</span>
284
+ <small>
285
+ {node.level}
286
+ {node.kind && NODE_KINDS[node.kind]
287
+ ? ` · ${NODE_KINDS[node.kind].label}`
288
+ : ''}{' '}
289
+ · {node.summary || node.id}
290
+ </small>
203
291
  </button>
204
292
  ))}
205
293
  </div>
@@ -10,7 +10,8 @@ const MAX_LENGTH = 4000;
10
10
  * a pathological tree cannot stall the search box.
11
11
  */
12
12
  export function collectText(value, depth = 0) {
13
- if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean') return '';
13
+ if (depth > MAX_DEPTH || value === null || value === undefined || typeof value === 'boolean')
14
+ return '';
14
15
  if (typeof value === 'string' || typeof value === 'number') return String(value);
15
16
  if (Array.isArray(value)) {
16
17
  return value.map(item => collectText(item, depth + 1)).join(' ');
@@ -28,7 +29,9 @@ export function collectText(value, depth = 0) {
28
29
  return parts.join(' ');
29
30
  }
30
31
  if (typeof value === 'object') {
31
- return Object.values(value).map(item => collectText(item, depth + 1)).join(' ');
32
+ return Object.values(value)
33
+ .map(item => collectText(item, depth + 1))
34
+ .join(' ');
32
35
  }
33
36
  return '';
34
37
  }
@@ -66,14 +69,20 @@ export function nodeSearchText(node) {
66
69
  node.invariants,
67
70
  node.failureModes,
68
71
  node.tradeoffs,
69
- node.customSections,
72
+ node.customSections
70
73
  ];
71
- return parts.map(part => collectText(part)).join(' ').slice(0, MAX_LENGTH).toLowerCase();
74
+ return parts
75
+ .map(part => collectText(part))
76
+ .join(' ')
77
+ .slice(0, MAX_LENGTH)
78
+ .toLowerCase();
72
79
  }
73
80
 
74
81
  /** Rank-free substring search returning at most `limit` nodes. */
75
82
  export function searchNodes(nodes, query, limit = 8) {
76
- const needle = String(query || '').trim().toLowerCase();
83
+ const needle = String(query || '')
84
+ .trim()
85
+ .toLowerCase();
77
86
  if (!needle) return [];
78
87
  const results = [];
79
88
  for (const node of nodes) {
@@ -13,15 +13,19 @@ function normalizeMode(value, fallback = 'dark') {
13
13
  * Compile-time defaults injected via Vite `define` (CLI --skin/--default-mode
14
14
  * or CONCEPT_ATLAS_* env vars). Absent defines resolve to the carrier fallbacks.
15
15
  */
16
- const BUILD_DEFAULT_SKIN = typeof __ATLAS_DEFAULT_SKIN__ === 'string'
17
- ? (normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN)
18
- : DEFAULT_SKIN;
19
- const BUILD_DEFAULT_MODE = typeof __ATLAS_DEFAULT_MODE__ === 'string' && (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
20
- ? __ATLAS_DEFAULT_MODE__
21
- : null;
22
- const BUILD_DEFAULT_STYLE = typeof __ATLAS_DEFAULT_STYLE__ === 'string'
23
- ? (normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE)
24
- : DEFAULT_STYLE;
16
+ const BUILD_DEFAULT_SKIN =
17
+ typeof __ATLAS_DEFAULT_SKIN__ === 'string'
18
+ ? normalizeSkin(__ATLAS_DEFAULT_SKIN__) || DEFAULT_SKIN
19
+ : DEFAULT_SKIN;
20
+ const BUILD_DEFAULT_MODE =
21
+ typeof __ATLAS_DEFAULT_MODE__ === 'string' &&
22
+ (__ATLAS_DEFAULT_MODE__ === 'dark' || __ATLAS_DEFAULT_MODE__ === 'light')
23
+ ? __ATLAS_DEFAULT_MODE__
24
+ : null;
25
+ const BUILD_DEFAULT_STYLE =
26
+ typeof __ATLAS_DEFAULT_STYLE__ === 'string'
27
+ ? normalizeStyle(__ATLAS_DEFAULT_STYLE__) || DEFAULT_STYLE
28
+ : DEFAULT_STYLE;
25
29
 
26
30
  function readStoredAppearance(fallbackMode) {
27
31
  const fallback = normalizeMode(fallbackMode, 'dark');
@@ -34,7 +38,7 @@ function readStoredAppearance(fallbackMode) {
34
38
  // Corrupt/missing mode falls back to the carrier default instead of
35
39
  // silently coercing to dark.
36
40
  mode: normalizeMode(parsed?.mode, fallback),
37
- style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE,
41
+ style: normalizeStyle(parsed?.style) || BUILD_DEFAULT_STYLE
38
42
  };
39
43
  }
40
44
  } catch {
@@ -67,7 +71,9 @@ export { readStoredAppearance, normalizeMode };
67
71
  * localStorage key so both pages stay in sync.
68
72
  */
69
73
  export function useAppearance({ defaultMode = 'dark' } = {}) {
70
- const [appearance, setAppearance] = useState(() => readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode));
74
+ const [appearance, setAppearance] = useState(() =>
75
+ readStoredAppearance(BUILD_DEFAULT_MODE || defaultMode)
76
+ );
71
77
 
72
78
  useEffect(() => {
73
79
  document.documentElement.setAttribute('data-skin', appearance.skin);
@@ -80,18 +86,26 @@ export function useAppearance({ defaultMode = 'dark' } = {}) {
80
86
  }
81
87
  }, [appearance]);
82
88
 
83
- const setSkin = useCallback((skin) => {
89
+ const setSkin = useCallback(skin => {
84
90
  setAppearance(prev => ({ ...prev, skin: normalizeSkin(skin) || prev.skin }));
85
91
  }, []);
86
- const setMode = useCallback((mode) => {
92
+ const setMode = useCallback(mode => {
87
93
  setAppearance(prev => ({ ...prev, mode: normalizeMode(mode, prev.mode) }));
88
94
  }, []);
89
95
  const toggleMode = useCallback(() => {
90
96
  setAppearance(prev => ({ ...prev, mode: prev.mode === 'dark' ? 'light' : 'dark' }));
91
97
  }, []);
92
- const setStyle = useCallback((style) => {
98
+ const setStyle = useCallback(style => {
93
99
  setAppearance(prev => ({ ...prev, style: normalizeStyle(style) || prev.style }));
94
100
  }, []);
95
101
 
96
- return { skin: appearance.skin, mode: appearance.mode, style: appearance.style, setSkin, setMode, toggleMode, setStyle };
102
+ return {
103
+ skin: appearance.skin,
104
+ mode: appearance.mode,
105
+ style: appearance.style,
106
+ setSkin,
107
+ setMode,
108
+ toggleMode,
109
+ setStyle
110
+ };
97
111
  }