docnest 0.1.15 → 0.1.17
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.
- package/README.md +11 -1
- package/dist/server.js +111 -0
- package/package.json +1 -1
- package/server/static/css/styles.css +261 -0
- package/server/static/js/core/doc-pdf-export.js +127 -18
- package/server/static/js/core/doc-search.js +424 -0
- package/server/views/partials/header.ejs +10 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DocNest
|
|
2
2
|
|
|
3
|
-
DocNest 是一个可复用的本地 Markdown 文档中心。文档内容归使用它的项目所有,DocNest
|
|
3
|
+
DocNest 是一个可复用的本地 Markdown 文档中心。文档内容归使用它的项目所有,DocNest 只提供查看、导航、搜索、Mermaid 图表、导出和本地 Watch 能力。
|
|
4
4
|
|
|
5
5
|
## 快速接入
|
|
6
6
|
|
|
@@ -56,6 +56,16 @@ export default defineConfig({
|
|
|
56
56
|
|
|
57
57
|
未配置 `appearance` 时使用 `slate-modern + auto`。旧配置中的 `current-docs` 会自动迁移到 `slate-modern`,正文视觉不会发生变化;旧版保存的 `markdown-theme` 明暗偏好也会自动迁移到新的项目级存储键。
|
|
58
58
|
|
|
59
|
+
### 文档搜索
|
|
60
|
+
|
|
61
|
+
普通阅读模式下,页面顶部提供文档搜索入口,也可以使用 `⌘K`(macOS)或
|
|
62
|
+
`Ctrl K`(Windows/Linux)打开。搜索索引在首次打开搜索时才加载,覆盖文档标题、目录路径、标题层级、正文和代码块;中文按连续字符匹配,英文和带空格的查询按多个词匹配。结果按标题、目录和正文命中位置综合排序,并显示安全的文本摘要。
|
|
63
|
+
|
|
64
|
+
搜索完全基于宿主项目自己的 Markdown 内容,不依赖 Algolia 或其他外部服务。动态服务通过同源
|
|
65
|
+
`/search-index.json` 提供索引;静态站点需要把同一份响应保存为站点根目录的
|
|
66
|
+
`search-index.json`,并保留页面上的静态站点标记,让结果链接指向 `/doc/<path>.html`。
|
|
67
|
+
受限模式不生成搜索索引,也不显示搜索入口,避免摘要绕过阅读限制。
|
|
68
|
+
|
|
59
69
|
PDF、页面水印和图表外带能力由一个开关统一控制,默认使用普通阅读模式:
|
|
60
70
|
|
|
61
71
|
```js
|
package/dist/server.js
CHANGED
|
@@ -422,6 +422,114 @@ app.get('/ready', async (req, res) => {
|
|
|
422
422
|
// 消费项目可以在通用文档路由前注入自己的访问控制;DocNest 不实现任何项目身份系统。
|
|
423
423
|
const beforeDocumentRoutes = globalThis.__DOCNEST_BEFORE_DOCUMENT_ROUTES__ || [];
|
|
424
424
|
beforeDocumentRoutes.forEach((middleware) => app.use(middleware));
|
|
425
|
+
const SEARCH_INDEX_VERSION = 1;
|
|
426
|
+
let searchIndexCache = null;
|
|
427
|
+
let searchIndexBuildPromise = null;
|
|
428
|
+
function collectSearchDocPaths(tree, paths = []) {
|
|
429
|
+
for (const item of tree) {
|
|
430
|
+
if (item.type === 'directory') {
|
|
431
|
+
collectSearchDocPaths(item.children, paths);
|
|
432
|
+
}
|
|
433
|
+
else if (item.type === 'file') {
|
|
434
|
+
paths.push(item.path);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return paths;
|
|
438
|
+
}
|
|
439
|
+
function normalizeSearchText(value) {
|
|
440
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
441
|
+
}
|
|
442
|
+
function getSearchInlineText(token) {
|
|
443
|
+
if (!Array.isArray(token.children))
|
|
444
|
+
return token.content;
|
|
445
|
+
return token.children.map((child) => {
|
|
446
|
+
if (child.type === 'softbreak' || child.type === 'hardbreak')
|
|
447
|
+
return ' ';
|
|
448
|
+
if (child.type === 'image')
|
|
449
|
+
return child.content || '';
|
|
450
|
+
return String(child.content || '').replace(/<[^>]+>/g, ' ');
|
|
451
|
+
}).join('');
|
|
452
|
+
}
|
|
453
|
+
function createSearchDocument(docPath, markdown) {
|
|
454
|
+
const tokens = md.parse(markdown, {});
|
|
455
|
+
const headings = [];
|
|
456
|
+
const textParts = [];
|
|
457
|
+
let insideHeading = false;
|
|
458
|
+
let title = '';
|
|
459
|
+
for (const token of tokens) {
|
|
460
|
+
if (token.type === 'heading_open') {
|
|
461
|
+
insideHeading = true;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (token.type === 'heading_close') {
|
|
465
|
+
insideHeading = false;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (token.type === 'inline') {
|
|
469
|
+
const text = normalizeSearchText(getSearchInlineText(token));
|
|
470
|
+
if (!text)
|
|
471
|
+
continue;
|
|
472
|
+
textParts.push(text);
|
|
473
|
+
if (insideHeading) {
|
|
474
|
+
headings.push(text);
|
|
475
|
+
if (!title)
|
|
476
|
+
title = text;
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (token.type === 'code_block' || token.type === 'fence') {
|
|
481
|
+
const code = normalizeSearchText(token.content);
|
|
482
|
+
if (code)
|
|
483
|
+
textParts.push(code);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const fallbackTitle = path.basename(docPath).replace(/\.(md|markdown)$/i, '');
|
|
487
|
+
return {
|
|
488
|
+
path: docPath,
|
|
489
|
+
title: title || fallbackTitle,
|
|
490
|
+
directory: path.posix.dirname(docPath) === '.' ? '' : path.posix.dirname(docPath),
|
|
491
|
+
headings,
|
|
492
|
+
text: normalizeSearchText(textParts.join(' ')),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async function buildSearchIndex() {
|
|
496
|
+
if (searchIndexCache)
|
|
497
|
+
return searchIndexCache;
|
|
498
|
+
if (searchIndexBuildPromise)
|
|
499
|
+
return searchIndexBuildPromise;
|
|
500
|
+
searchIndexBuildPromise = (async () => {
|
|
501
|
+
const tree = await getDocTree(DOCS_DIR);
|
|
502
|
+
const paths = collectSearchDocPaths(tree);
|
|
503
|
+
const documents = [];
|
|
504
|
+
for (const docPath of paths) {
|
|
505
|
+
const markdown = await storageReadFile(docPath);
|
|
506
|
+
documents.push(createSearchDocument(docPath, markdown));
|
|
507
|
+
}
|
|
508
|
+
return { version: SEARCH_INDEX_VERSION, documents };
|
|
509
|
+
})()
|
|
510
|
+
.then((index) => {
|
|
511
|
+
searchIndexCache = index;
|
|
512
|
+
return index;
|
|
513
|
+
})
|
|
514
|
+
.finally(() => {
|
|
515
|
+
searchIndexBuildPromise = null;
|
|
516
|
+
});
|
|
517
|
+
return searchIndexBuildPromise;
|
|
518
|
+
}
|
|
519
|
+
function invalidateSearchIndex() {
|
|
520
|
+
searchIndexCache = null;
|
|
521
|
+
}
|
|
522
|
+
app.get('/search-index.json', async (req, res) => {
|
|
523
|
+
if (DOCS_RESTRICTED_MODE) {
|
|
524
|
+
return res.status(404).json({ ok: false, error: '搜索功能已关闭。' });
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
return res.json(await buildSearchIndex());
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
return res.status(503).json({ ok: false, error: `搜索索引暂不可用:${error.message}` });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
425
533
|
// 获取文档目录树(排除隐藏目录和系统文件)
|
|
426
534
|
async function getDocTree(dir, basePath = '') {
|
|
427
535
|
const entries = await storageReadDir(basePath);
|
|
@@ -882,6 +990,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
|
|
|
882
990
|
return;
|
|
883
991
|
}
|
|
884
992
|
const relativePath = normalizePath(filePath);
|
|
993
|
+
invalidateSearchIndex();
|
|
885
994
|
console.log(`📝 文件已更新: ${relativePath}`);
|
|
886
995
|
try {
|
|
887
996
|
// 重新读取文件内容并渲染
|
|
@@ -904,6 +1013,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
|
|
|
904
1013
|
watcher.on('add', (filePath) => {
|
|
905
1014
|
if (filePath.endsWith('.md')) {
|
|
906
1015
|
const relativePath = normalizePath(filePath);
|
|
1016
|
+
invalidateSearchIndex();
|
|
907
1017
|
console.log(`➕ 新文件已添加: ${relativePath}`);
|
|
908
1018
|
io.emit('file-added', { path: relativePath });
|
|
909
1019
|
}
|
|
@@ -911,6 +1021,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
|
|
|
911
1021
|
watcher.on('unlink', (filePath) => {
|
|
912
1022
|
if (filePath.endsWith('.md')) {
|
|
913
1023
|
const relativePath = normalizePath(filePath);
|
|
1024
|
+
invalidateSearchIndex();
|
|
914
1025
|
console.log(`🗑️ 文件已删除: ${relativePath}`);
|
|
915
1026
|
io.emit('file-deleted', { path: relativePath });
|
|
916
1027
|
}
|
package/package.json
CHANGED
|
@@ -167,6 +167,39 @@ body {
|
|
|
167
167
|
min-width: 112px;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
.doc-search-trigger {
|
|
171
|
+
min-width: 168px;
|
|
172
|
+
justify-content: flex-start;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.doc-search-trigger .header-action-text {
|
|
176
|
+
flex: 1;
|
|
177
|
+
text-align: left;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.doc-search-icon {
|
|
181
|
+
display: inline-block;
|
|
182
|
+
font-size: 1.2rem;
|
|
183
|
+
line-height: 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.doc-search-shortcut,
|
|
187
|
+
.doc-search-input-key {
|
|
188
|
+
display: inline-flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
justify-content: center;
|
|
191
|
+
min-width: 2.25rem;
|
|
192
|
+
padding: 0.15rem 0.35rem;
|
|
193
|
+
border: 1px solid var(--linear-border);
|
|
194
|
+
border-radius: 5px;
|
|
195
|
+
background: var(--linear-bg);
|
|
196
|
+
color: var(--linear-text-muted);
|
|
197
|
+
font: inherit;
|
|
198
|
+
font-size: 0.6875rem;
|
|
199
|
+
line-height: 1.2;
|
|
200
|
+
white-space: nowrap;
|
|
201
|
+
}
|
|
202
|
+
|
|
170
203
|
.theme-icon {
|
|
171
204
|
display: inline-block;
|
|
172
205
|
line-height: 1;
|
|
@@ -553,6 +586,10 @@ body[data-theme="dark"] .dir-toggle.active {
|
|
|
553
586
|
margin: 0 auto;
|
|
554
587
|
}
|
|
555
588
|
|
|
589
|
+
body.doc-search-open {
|
|
590
|
+
overflow: hidden;
|
|
591
|
+
}
|
|
592
|
+
|
|
556
593
|
.doc-toc {
|
|
557
594
|
position: sticky;
|
|
558
595
|
top: 12px;
|
|
@@ -2137,6 +2174,8 @@ body[data-theme="dark"] .tree-nav::-webkit-scrollbar-thumb:hover {
|
|
|
2137
2174
|
.theme-toggle,
|
|
2138
2175
|
.download-pdf-btn,
|
|
2139
2176
|
.print-btn,
|
|
2177
|
+
.doc-search-modal,
|
|
2178
|
+
.doc-search-trigger,
|
|
2140
2179
|
.sidebar,
|
|
2141
2180
|
.sidebar-resizer {
|
|
2142
2181
|
display: none !important;
|
|
@@ -2161,6 +2200,228 @@ body[data-theme="dark"] .tree-nav::-webkit-scrollbar-thumb:hover {
|
|
|
2161
2200
|
}
|
|
2162
2201
|
}
|
|
2163
2202
|
|
|
2203
|
+
.doc-search-modal {
|
|
2204
|
+
position: fixed;
|
|
2205
|
+
inset: 0;
|
|
2206
|
+
display: flex;
|
|
2207
|
+
align-items: flex-start;
|
|
2208
|
+
justify-content: center;
|
|
2209
|
+
box-sizing: border-box;
|
|
2210
|
+
padding: clamp(4rem, 12vh, 8rem) 1rem 2rem;
|
|
2211
|
+
background: rgba(15, 23, 42, 0.48);
|
|
2212
|
+
z-index: 20030;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
.doc-search-modal[hidden] {
|
|
2216
|
+
display: none;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
.doc-search-modal__panel {
|
|
2220
|
+
display: flex;
|
|
2221
|
+
flex-direction: column;
|
|
2222
|
+
width: min(720px, 100%);
|
|
2223
|
+
max-height: min(680px, calc(100vh - 6rem));
|
|
2224
|
+
overflow: hidden;
|
|
2225
|
+
box-sizing: border-box;
|
|
2226
|
+
border: 1px solid var(--linear-border);
|
|
2227
|
+
border-radius: 16px;
|
|
2228
|
+
background: var(--linear-bg);
|
|
2229
|
+
color: var(--linear-text);
|
|
2230
|
+
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
.doc-search-modal__topline {
|
|
2234
|
+
display: flex;
|
|
2235
|
+
align-items: center;
|
|
2236
|
+
justify-content: space-between;
|
|
2237
|
+
padding: 1rem 1.15rem 0.75rem;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
.doc-search-modal__eyebrow {
|
|
2241
|
+
color: var(--linear-text-muted);
|
|
2242
|
+
font-size: 0.75rem;
|
|
2243
|
+
font-weight: 700;
|
|
2244
|
+
letter-spacing: 0.08em;
|
|
2245
|
+
text-transform: uppercase;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
.doc-search-modal__close {
|
|
2249
|
+
width: 2rem;
|
|
2250
|
+
height: 2rem;
|
|
2251
|
+
border: 0;
|
|
2252
|
+
border-radius: 8px;
|
|
2253
|
+
background: transparent;
|
|
2254
|
+
color: var(--linear-text-muted);
|
|
2255
|
+
cursor: pointer;
|
|
2256
|
+
font-size: 1.4rem;
|
|
2257
|
+
line-height: 1;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
.doc-search-modal__close:hover {
|
|
2261
|
+
background: var(--linear-bg-subtle);
|
|
2262
|
+
color: var(--linear-text);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
.doc-search-input-wrap {
|
|
2266
|
+
display: flex;
|
|
2267
|
+
align-items: center;
|
|
2268
|
+
gap: 0.75rem;
|
|
2269
|
+
margin: 0 1.15rem;
|
|
2270
|
+
padding: 0.7rem 0.8rem;
|
|
2271
|
+
border: 1px solid var(--linear-border);
|
|
2272
|
+
border-radius: 10px;
|
|
2273
|
+
background: var(--linear-bg-subtle);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
.doc-search-input-wrap:focus-within {
|
|
2277
|
+
border-color: var(--linear-accent);
|
|
2278
|
+
box-shadow: 0 0 0 3px var(--doc-accent-soft);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
.doc-search-input-icon {
|
|
2282
|
+
flex: 0 0 auto;
|
|
2283
|
+
color: var(--linear-accent);
|
|
2284
|
+
font-size: 1.35rem;
|
|
2285
|
+
line-height: 1;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
.doc-search-input {
|
|
2289
|
+
min-width: 0;
|
|
2290
|
+
flex: 1;
|
|
2291
|
+
border: 0;
|
|
2292
|
+
outline: 0;
|
|
2293
|
+
background: transparent;
|
|
2294
|
+
color: var(--linear-text);
|
|
2295
|
+
font: inherit;
|
|
2296
|
+
font-size: 1rem;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
.doc-search-input::placeholder {
|
|
2300
|
+
color: var(--linear-text-muted);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
.doc-search-input:disabled {
|
|
2304
|
+
cursor: wait;
|
|
2305
|
+
opacity: 0.65;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
.doc-search-status {
|
|
2309
|
+
min-height: 1.25rem;
|
|
2310
|
+
padding: 0.75rem 1.25rem 0.5rem;
|
|
2311
|
+
color: var(--linear-text-muted);
|
|
2312
|
+
font-size: 0.75rem;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
.doc-search-results {
|
|
2316
|
+
min-height: 5rem;
|
|
2317
|
+
overflow-y: auto;
|
|
2318
|
+
padding: 0 0.6rem 0.6rem;
|
|
2319
|
+
overscroll-behavior: contain;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
.doc-search-result {
|
|
2323
|
+
position: relative;
|
|
2324
|
+
display: block;
|
|
2325
|
+
padding: 0.8rem 2.5rem 0.75rem 0.8rem;
|
|
2326
|
+
border: 1px solid transparent;
|
|
2327
|
+
border-radius: 10px;
|
|
2328
|
+
color: var(--linear-text);
|
|
2329
|
+
text-decoration: none;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
.doc-search-result:hover,
|
|
2333
|
+
.doc-search-result.is-selected {
|
|
2334
|
+
border-color: var(--linear-border);
|
|
2335
|
+
background: var(--linear-bg-subtle);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
.doc-search-result__title {
|
|
2339
|
+
overflow: hidden;
|
|
2340
|
+
color: var(--linear-text);
|
|
2341
|
+
font-size: 0.9375rem;
|
|
2342
|
+
font-weight: 650;
|
|
2343
|
+
line-height: 1.4;
|
|
2344
|
+
text-overflow: ellipsis;
|
|
2345
|
+
white-space: nowrap;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
.doc-search-result__path {
|
|
2349
|
+
margin-top: 0.15rem;
|
|
2350
|
+
overflow: hidden;
|
|
2351
|
+
color: var(--linear-accent);
|
|
2352
|
+
font-size: 0.6875rem;
|
|
2353
|
+
line-height: 1.4;
|
|
2354
|
+
text-overflow: ellipsis;
|
|
2355
|
+
white-space: nowrap;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
.doc-search-result__snippet {
|
|
2359
|
+
display: -webkit-box;
|
|
2360
|
+
overflow: hidden;
|
|
2361
|
+
margin: 0.35rem 0 0;
|
|
2362
|
+
color: var(--linear-text-muted);
|
|
2363
|
+
font-size: 0.8125rem;
|
|
2364
|
+
line-height: 1.5;
|
|
2365
|
+
-webkit-box-orient: vertical;
|
|
2366
|
+
-webkit-line-clamp: 2;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
.doc-search-result__arrow {
|
|
2370
|
+
position: absolute;
|
|
2371
|
+
top: 50%;
|
|
2372
|
+
right: 0.9rem;
|
|
2373
|
+
color: var(--linear-text-muted);
|
|
2374
|
+
font-size: 1rem;
|
|
2375
|
+
transform: translateY(-50%);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
.doc-search-result.is-selected .doc-search-result__arrow,
|
|
2379
|
+
.doc-search-result:hover .doc-search-result__arrow {
|
|
2380
|
+
color: var(--linear-accent);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
.doc-search-result mark {
|
|
2384
|
+
border-radius: 3px;
|
|
2385
|
+
background: var(--doc-accent-soft);
|
|
2386
|
+
color: inherit;
|
|
2387
|
+
font-weight: 700;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
.doc-search-empty {
|
|
2391
|
+
display: grid;
|
|
2392
|
+
min-height: 5rem;
|
|
2393
|
+
place-items: center;
|
|
2394
|
+
padding: 1.5rem;
|
|
2395
|
+
color: var(--linear-text-muted);
|
|
2396
|
+
font-size: 0.8125rem;
|
|
2397
|
+
text-align: center;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
.doc-search-hint {
|
|
2401
|
+
display: flex;
|
|
2402
|
+
gap: 1rem;
|
|
2403
|
+
padding: 0.7rem 1.15rem 0.85rem;
|
|
2404
|
+
border-top: 1px solid var(--linear-border);
|
|
2405
|
+
color: var(--linear-text-muted);
|
|
2406
|
+
font-size: 0.6875rem;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
@media (max-width: 768px) {
|
|
2410
|
+
.doc-search-modal {
|
|
2411
|
+
padding: 1rem;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
.doc-search-modal__panel {
|
|
2415
|
+
max-height: calc(100vh - 2rem);
|
|
2416
|
+
border-radius: 12px;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
.doc-search-hint {
|
|
2420
|
+
gap: 0.65rem;
|
|
2421
|
+
flex-wrap: wrap;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2164
2425
|
.pdf-export-modal {
|
|
2165
2426
|
position: fixed;
|
|
2166
2427
|
inset: 0;
|
|
@@ -230,15 +230,16 @@
|
|
|
230
230
|
});
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
function renderCanvas(root) {
|
|
233
|
+
function renderCanvas(root, pageBackground) {
|
|
234
234
|
var width = Math.max(root.scrollWidth, root.offsetWidth, 960);
|
|
235
235
|
var height = Math.max(root.scrollHeight, root.offsetHeight, 1);
|
|
236
|
+
var background = pageBackground || getExportPageBackground(root);
|
|
236
237
|
|
|
237
238
|
return window.html2canvas(root, {
|
|
238
239
|
scale: 2,
|
|
239
240
|
useCORS: true,
|
|
240
241
|
allowTaint: true,
|
|
241
|
-
backgroundColor: '
|
|
242
|
+
backgroundColor: 'rgb(' + background.r + ', ' + background.g + ', ' + background.b + ')',
|
|
242
243
|
logging: false,
|
|
243
244
|
width: width,
|
|
244
245
|
height: height,
|
|
@@ -250,6 +251,90 @@
|
|
|
250
251
|
});
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
function parseCssColor(value) {
|
|
255
|
+
var color = String(value || '').trim().toLowerCase();
|
|
256
|
+
if (!color || color === 'transparent') return null;
|
|
257
|
+
|
|
258
|
+
if (color.charAt(0) === '#') {
|
|
259
|
+
var hex = color.slice(1);
|
|
260
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
261
|
+
hex = hex.split('').map(function(part) { return part + part; }).join('');
|
|
262
|
+
}
|
|
263
|
+
if (hex.length === 6 || hex.length === 8 && /^[0-9a-f]+$/.test(hex)) {
|
|
264
|
+
var hasAlpha = hex.length === 8;
|
|
265
|
+
if (!hasAlpha && !/^[0-9a-f]+$/.test(hex)) return null;
|
|
266
|
+
return {
|
|
267
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
268
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
269
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
270
|
+
alpha: hasAlpha ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
var rgb = color.match(/^rgba?\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)(?:,\s*([^)]+))?\s*\)$/);
|
|
277
|
+
if (!rgb) return null;
|
|
278
|
+
|
|
279
|
+
function channel(value) {
|
|
280
|
+
var parsed = parseFloat(value);
|
|
281
|
+
if (!Number.isFinite(parsed)) return null;
|
|
282
|
+
if (String(value).indexOf('%') !== -1) parsed = parsed * 2.55;
|
|
283
|
+
return Math.max(0, Math.min(255, Math.round(parsed)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var red = channel(rgb[1]);
|
|
287
|
+
var green = channel(rgb[2]);
|
|
288
|
+
var blue = channel(rgb[3]);
|
|
289
|
+
var alpha = rgb[4] === undefined ? 1 : parseFloat(rgb[4]);
|
|
290
|
+
if (red === null || green === null || blue === null || !Number.isFinite(alpha)) return null;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
r: red,
|
|
294
|
+
g: green,
|
|
295
|
+
b: blue,
|
|
296
|
+
alpha: Math.max(0, Math.min(1, alpha)),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function readComputedColor(style, property) {
|
|
301
|
+
if (!style) return null;
|
|
302
|
+
var value = property.indexOf('--') === 0 && typeof style.getPropertyValue === 'function'
|
|
303
|
+
? style.getPropertyValue(property)
|
|
304
|
+
: style[property] || (typeof style.getPropertyValue === 'function' ? style.getPropertyValue(property) : '');
|
|
305
|
+
return parseCssColor(value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withoutAlpha(color) {
|
|
309
|
+
return { r: color.r, g: color.g, b: color.b };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function blendColors(foreground, background) {
|
|
313
|
+
return {
|
|
314
|
+
r: Math.round(foreground.r * foreground.alpha + background.r * (1 - foreground.alpha)),
|
|
315
|
+
g: Math.round(foreground.g * foreground.alpha + background.g * (1 - foreground.alpha)),
|
|
316
|
+
b: Math.round(foreground.b * foreground.alpha + background.b * (1 - foreground.alpha)),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getExportPageBackground(root) {
|
|
321
|
+
var getComputedStyle = typeof window.getComputedStyle === 'function'
|
|
322
|
+
? window.getComputedStyle.bind(window)
|
|
323
|
+
: null;
|
|
324
|
+
var rootStyle = getComputedStyle ? getComputedStyle(root) : null;
|
|
325
|
+
var bodyStyle = getComputedStyle && document.body ? getComputedStyle(document.body) : null;
|
|
326
|
+
var themeSurface = readComputedColor(rootStyle, '--doc-surface-raised')
|
|
327
|
+
|| readComputedColor(rootStyle, '--linear-bg')
|
|
328
|
+
|| readComputedColor(bodyStyle, '--linear-bg')
|
|
329
|
+
|| readComputedColor(bodyStyle, 'backgroundColor')
|
|
330
|
+
|| parseCssColor('#ffffff');
|
|
331
|
+
var rootBackground = readComputedColor(rootStyle, 'backgroundColor');
|
|
332
|
+
|
|
333
|
+
if (!rootBackground || rootBackground.alpha === 0) return withoutAlpha(themeSurface);
|
|
334
|
+
if (rootBackground.alpha < 1) return blendColors(rootBackground, themeSurface);
|
|
335
|
+
return withoutAlpha(rootBackground);
|
|
336
|
+
}
|
|
337
|
+
|
|
253
338
|
function getCanvasContext(canvas) {
|
|
254
339
|
return canvas.getContext('2d', { willReadFrequently: true }) || canvas.getContext('2d');
|
|
255
340
|
}
|
|
@@ -387,7 +472,15 @@
|
|
|
387
472
|
return Math.max(minBreakY - startY, searchStartY + bestRow - startY);
|
|
388
473
|
}
|
|
389
474
|
|
|
390
|
-
function
|
|
475
|
+
function fillPdfPageBackground(pdf, pageBackground) {
|
|
476
|
+
if (!pageBackground || typeof pdf.setFillColor !== 'function' || typeof pdf.rect !== 'function') return;
|
|
477
|
+
var pageWidth = pdf.internal.pageSize.getWidth();
|
|
478
|
+
var pageHeight = pdf.internal.pageSize.getHeight();
|
|
479
|
+
pdf.setFillColor(pageBackground.r, pageBackground.g, pageBackground.b);
|
|
480
|
+
pdf.rect(0, 0, pageWidth, pageHeight, 'F');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark, protectedRegions, pageBackground) {
|
|
391
484
|
var pageWidth = pdf.internal.pageSize.getWidth();
|
|
392
485
|
var pageHeight = pdf.internal.pageSize.getHeight();
|
|
393
486
|
var usableWidth = pageWidth - margin * 2;
|
|
@@ -454,6 +547,7 @@
|
|
|
454
547
|
if (pageIndex > 0) {
|
|
455
548
|
pdf.addPage();
|
|
456
549
|
}
|
|
550
|
+
fillPdfPageBackground(pdf, pageBackground);
|
|
457
551
|
|
|
458
552
|
var imageWidth = Math.min(usableWidth, (usableHeight * canvas.width) / sliceHeight);
|
|
459
553
|
var imageHeight = (sliceHeight * imageWidth) / canvas.width;
|
|
@@ -474,25 +568,39 @@
|
|
|
474
568
|
}
|
|
475
569
|
|
|
476
570
|
function drawWatermarkPattern(context, width, height, referenceHeight, watermarkText) {
|
|
477
|
-
if (!watermarkText) return;
|
|
478
|
-
var
|
|
479
|
-
var
|
|
480
|
-
var
|
|
481
|
-
var startX = tileWidth / 2;
|
|
482
|
-
var startY = tileHeight / 2;
|
|
571
|
+
if (!watermarkText || width <= 0 || height <= 0 || referenceHeight <= 0) return;
|
|
572
|
+
var rotation = -Math.PI / 6;
|
|
573
|
+
var fontSize = Math.max(36, Math.min(56, Math.round(Math.min(width, referenceHeight) * 0.022)));
|
|
574
|
+
var font = '500 ' + fontSize + 'px "PingFang SC", "Microsoft YaHei", sans-serif';
|
|
483
575
|
|
|
484
576
|
context.save();
|
|
485
577
|
context.textAlign = 'center';
|
|
486
578
|
context.textBaseline = 'middle';
|
|
487
|
-
context.fillStyle = 'rgba(30, 41, 59, 0.
|
|
488
|
-
context.font =
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
579
|
+
context.fillStyle = 'rgba(30, 41, 59, 0.04)';
|
|
580
|
+
context.font = font;
|
|
581
|
+
|
|
582
|
+
// Use the actual rendered text width to calculate the repeat distance. The
|
|
583
|
+
// old fixed four-column grid was narrower than this long title, so adjacent
|
|
584
|
+
// watermarks collided and looked like a rough gray texture.
|
|
585
|
+
var textWidth = typeof context.measureText === 'function'
|
|
586
|
+
? context.measureText(watermarkText).width
|
|
587
|
+
: fontSize * Math.max(watermarkText.length, 1) * 0.9;
|
|
588
|
+
var textHeight = fontSize;
|
|
589
|
+
var cos = Math.abs(Math.cos(rotation));
|
|
590
|
+
var sin = Math.abs(Math.sin(rotation));
|
|
591
|
+
var rotatedWidth = textWidth * cos + textHeight * sin;
|
|
592
|
+
var rotatedHeight = textWidth * sin + textHeight * cos;
|
|
593
|
+
var columnStep = Math.max(width / 3, rotatedWidth * 1.55);
|
|
594
|
+
var rowStep = Math.max(referenceHeight / 3, rotatedHeight * 2.75);
|
|
595
|
+
var startX = columnStep / 2;
|
|
596
|
+
var startY = rowStep / 2;
|
|
597
|
+
|
|
598
|
+
for (var rowIndex = 0, y = startY; y < height; rowIndex += 1, y += rowStep) {
|
|
599
|
+
var rowOffsetX = rowIndex % 2 === 1 ? columnStep / 2 : 0;
|
|
600
|
+
for (var x = startX; x + rowOffsetX < width; x += columnStep) {
|
|
493
601
|
context.save();
|
|
494
602
|
context.translate(x + rowOffsetX, y);
|
|
495
|
-
context.rotate(
|
|
603
|
+
context.rotate(rotation);
|
|
496
604
|
context.fillText(watermarkText, 0, 0);
|
|
497
605
|
context.restore();
|
|
498
606
|
}
|
|
@@ -671,15 +779,16 @@
|
|
|
671
779
|
format: 'a4',
|
|
672
780
|
compress: true,
|
|
673
781
|
});
|
|
782
|
+
var pageBackground = getExportPageBackground(exportRoot);
|
|
674
783
|
return waitForExportReady(exportRoot)
|
|
675
784
|
.then(function() {
|
|
676
|
-
return renderCanvas(exportRoot);
|
|
785
|
+
return renderCanvas(exportRoot, pageBackground);
|
|
677
786
|
})
|
|
678
787
|
.then(function(canvas) {
|
|
679
788
|
var canvasScale = canvas.height / Math.max(exportRoot.scrollHeight, 1);
|
|
680
789
|
var blockBreakpoints = collectBlockBreakpoints(exportRoot, canvasScale);
|
|
681
790
|
var protectedRegions = collectProtectedRegions(exportRoot, canvasScale);
|
|
682
|
-
addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark, protectedRegions);
|
|
791
|
+
addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark, protectedRegions, pageBackground);
|
|
683
792
|
pdf.save(getExportFileName() + '.pdf');
|
|
684
793
|
})
|
|
685
794
|
.finally(function() {
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var MAX_RESULTS = 12;
|
|
5
|
+
var SNIPPET_LENGTH = 160;
|
|
6
|
+
var SEARCH_INDEX_PATH = '/search-index.json';
|
|
7
|
+
var state = {
|
|
8
|
+
index: null,
|
|
9
|
+
indexPromise: null,
|
|
10
|
+
modal: null,
|
|
11
|
+
trigger: null,
|
|
12
|
+
lastFocus: null,
|
|
13
|
+
results: [],
|
|
14
|
+
selectedIndex: -1,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getAssetUrl(path) {
|
|
18
|
+
var base = document.querySelector('base');
|
|
19
|
+
if (base && base.getAttribute('href')) {
|
|
20
|
+
return base.getAttribute('href').replace(/\/?$/, '/') + path.replace(/^\//, '');
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isStaticSite() {
|
|
26
|
+
return document.documentElement.getAttribute('data-docnest-static') === 'true';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeText(value) {
|
|
30
|
+
var text = String(value || '');
|
|
31
|
+
if (typeof text.normalize === 'function') text = text.normalize('NFKC');
|
|
32
|
+
return text.trim().toLocaleLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getQueryTerms(query) {
|
|
36
|
+
return normalizeText(query).split(/\s+/).filter(function(term) { return term; });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countOccurrences(text, term) {
|
|
40
|
+
var count = 0;
|
|
41
|
+
var offset = 0;
|
|
42
|
+
while (offset < text.length) {
|
|
43
|
+
var found = text.indexOf(term, offset);
|
|
44
|
+
if (found === -1) break;
|
|
45
|
+
count += 1;
|
|
46
|
+
offset = found + Math.max(term.length, 1);
|
|
47
|
+
}
|
|
48
|
+
return count;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getDocumentFields(document) {
|
|
52
|
+
var title = normalizeText(document.title);
|
|
53
|
+
var directory = normalizeText(document.directory);
|
|
54
|
+
var path = normalizeText(document.path);
|
|
55
|
+
var headings = normalizeText(Array.isArray(document.headings) ? document.headings.join(' ') : '');
|
|
56
|
+
var text = normalizeText(document.text);
|
|
57
|
+
return {
|
|
58
|
+
title: title,
|
|
59
|
+
directory: directory,
|
|
60
|
+
path: path,
|
|
61
|
+
headings: headings,
|
|
62
|
+
text: text,
|
|
63
|
+
all: [title, directory, path, headings, text].join('\n'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scoreDocument(document, query, terms) {
|
|
68
|
+
var fields = getDocumentFields(document);
|
|
69
|
+
for (var i = 0; i < terms.length; i += 1) {
|
|
70
|
+
if (!fields.all.includes(terms[i])) return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var score = 0;
|
|
74
|
+
terms.forEach(function(term) {
|
|
75
|
+
if (fields.title.includes(term)) score += 120;
|
|
76
|
+
if (fields.headings.includes(term)) score += 70;
|
|
77
|
+
if (fields.directory.includes(term)) score += 40;
|
|
78
|
+
if (fields.path.includes(term)) score += 30;
|
|
79
|
+
score += Math.min(countOccurrences(fields.text, term), 8) * 4;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
var phrase = normalizeText(query);
|
|
83
|
+
if (phrase.length > 1 && fields.all.includes(phrase)) score += 60;
|
|
84
|
+
return score;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function searchDocuments(query) {
|
|
88
|
+
var terms = getQueryTerms(query);
|
|
89
|
+
if (!state.index || terms.length === 0) return [];
|
|
90
|
+
|
|
91
|
+
return state.index.map(function(document, index) {
|
|
92
|
+
return {
|
|
93
|
+
document: document,
|
|
94
|
+
index: index,
|
|
95
|
+
score: scoreDocument(document, query, terms),
|
|
96
|
+
};
|
|
97
|
+
}).filter(function(result) {
|
|
98
|
+
return result.score !== null;
|
|
99
|
+
}).sort(function(left, right) {
|
|
100
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
101
|
+
return left.index - right.index;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getSnippet(document, terms) {
|
|
106
|
+
var text = String(document.text || '').replace(/\s+/g, ' ').trim();
|
|
107
|
+
if (!text) text = String((document.headings || []).join(' ')).trim();
|
|
108
|
+
if (!text) return '';
|
|
109
|
+
|
|
110
|
+
var normalized = normalizeText(text);
|
|
111
|
+
var matchIndex = -1;
|
|
112
|
+
terms.forEach(function(term) {
|
|
113
|
+
var index = normalized.indexOf(term);
|
|
114
|
+
if (index !== -1 && (matchIndex === -1 || index < matchIndex)) matchIndex = index;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (matchIndex === -1) matchIndex = 0;
|
|
118
|
+
var start = Math.max(0, matchIndex - 54);
|
|
119
|
+
var end = Math.min(text.length, start + SNIPPET_LENGTH);
|
|
120
|
+
if (end - start < SNIPPET_LENGTH && start > 0) {
|
|
121
|
+
start = Math.max(0, end - SNIPPET_LENGTH);
|
|
122
|
+
}
|
|
123
|
+
return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeRegExp(value) {
|
|
127
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function appendHighlightedText(container, text, terms) {
|
|
131
|
+
var value = String(text || '');
|
|
132
|
+
var uniqueTerms = Array.from(new Set(terms.filter(function(term) { return term; })))
|
|
133
|
+
.sort(function(left, right) { return right.length - left.length; });
|
|
134
|
+
if (uniqueTerms.length === 0) {
|
|
135
|
+
container.appendChild(document.createTextNode(value));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var pattern = new RegExp('(' + uniqueTerms.map(escapeRegExp).join('|') + ')', 'gi');
|
|
140
|
+
var cursor = 0;
|
|
141
|
+
value.replace(pattern, function(match, ignored, offset) {
|
|
142
|
+
if (offset > cursor) container.appendChild(document.createTextNode(value.slice(cursor, offset)));
|
|
143
|
+
var mark = document.createElement('mark');
|
|
144
|
+
mark.textContent = match;
|
|
145
|
+
container.appendChild(mark);
|
|
146
|
+
cursor = offset + match.length;
|
|
147
|
+
return match;
|
|
148
|
+
});
|
|
149
|
+
if (cursor < value.length) container.appendChild(document.createTextNode(value.slice(cursor)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function encodeDocumentPath(documentPath) {
|
|
153
|
+
return String(documentPath || '').split('/').map(function(segment) {
|
|
154
|
+
return encodeURIComponent(segment);
|
|
155
|
+
}).join('/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildDocumentUrl(documentPath) {
|
|
159
|
+
var normalizedPath = String(documentPath || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
160
|
+
if (isStaticSite()) {
|
|
161
|
+
var staticPath = normalizedPath.replace(/\.(md|markdown)$/i, '.html');
|
|
162
|
+
return getAssetUrl('/doc/' + encodeDocumentPath(staticPath));
|
|
163
|
+
}
|
|
164
|
+
return getAssetUrl('/doc?path=' + encodeURIComponent(normalizedPath));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setStatus(message) {
|
|
168
|
+
if (state.modal && state.modal.status) state.modal.status.textContent = message;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createEmptyState(message) {
|
|
172
|
+
var empty = document.createElement('div');
|
|
173
|
+
empty.className = 'doc-search-empty';
|
|
174
|
+
empty.textContent = message;
|
|
175
|
+
return empty;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setSelectedIndex(index) {
|
|
179
|
+
if (!state.modal || state.results.length === 0) {
|
|
180
|
+
state.selectedIndex = -1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
state.selectedIndex = Math.max(0, Math.min(index, state.results.length - 1));
|
|
184
|
+
var options = state.modal.results.querySelectorAll('.doc-search-result');
|
|
185
|
+
options.forEach(function(option, optionIndex) {
|
|
186
|
+
var selected = optionIndex === state.selectedIndex;
|
|
187
|
+
option.classList.toggle('is-selected', selected);
|
|
188
|
+
option.setAttribute('aria-selected', String(selected));
|
|
189
|
+
});
|
|
190
|
+
var selectedOption = options[state.selectedIndex];
|
|
191
|
+
if (selectedOption) {
|
|
192
|
+
state.modal.input.setAttribute('aria-activedescendant', selectedOption.id);
|
|
193
|
+
selectedOption.scrollIntoView({ block: 'nearest' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createResult(result, resultIndex, terms) {
|
|
198
|
+
var documentData = result.document;
|
|
199
|
+
var option = document.createElement('a');
|
|
200
|
+
option.className = 'doc-search-result';
|
|
201
|
+
option.id = 'doc-search-result-' + resultIndex;
|
|
202
|
+
option.href = buildDocumentUrl(documentData.path);
|
|
203
|
+
option.setAttribute('role', 'option');
|
|
204
|
+
option.setAttribute('aria-selected', 'false');
|
|
205
|
+
|
|
206
|
+
var title = document.createElement('div');
|
|
207
|
+
title.className = 'doc-search-result__title';
|
|
208
|
+
appendHighlightedText(title, documentData.title || documentData.path, terms);
|
|
209
|
+
|
|
210
|
+
var pathLabel = document.createElement('div');
|
|
211
|
+
pathLabel.className = 'doc-search-result__path';
|
|
212
|
+
appendHighlightedText(pathLabel, documentData.path, terms);
|
|
213
|
+
|
|
214
|
+
var snippet = document.createElement('p');
|
|
215
|
+
snippet.className = 'doc-search-result__snippet';
|
|
216
|
+
appendHighlightedText(snippet, getSnippet(documentData, terms), terms);
|
|
217
|
+
|
|
218
|
+
var arrow = document.createElement('span');
|
|
219
|
+
arrow.className = 'doc-search-result__arrow';
|
|
220
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
221
|
+
arrow.textContent = '↗';
|
|
222
|
+
|
|
223
|
+
option.appendChild(title);
|
|
224
|
+
option.appendChild(pathLabel);
|
|
225
|
+
if (snippet.textContent) option.appendChild(snippet);
|
|
226
|
+
option.appendChild(arrow);
|
|
227
|
+
option.addEventListener('mouseenter', function() { setSelectedIndex(resultIndex); });
|
|
228
|
+
option.addEventListener('click', function(event) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
closeSearch();
|
|
231
|
+
var url = buildDocumentUrl(documentData.path);
|
|
232
|
+
if (!isStaticSite() && typeof window.navigateToDoc === 'function') {
|
|
233
|
+
window.navigateToDoc(url);
|
|
234
|
+
} else {
|
|
235
|
+
window.location.href = url;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return option;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderResults() {
|
|
242
|
+
if (!state.modal) return;
|
|
243
|
+
var query = state.modal.input.value.trim();
|
|
244
|
+
state.modal.results.textContent = '';
|
|
245
|
+
state.results = [];
|
|
246
|
+
state.selectedIndex = -1;
|
|
247
|
+
|
|
248
|
+
if (!query) {
|
|
249
|
+
setStatus('输入标题、目录或正文关键词');
|
|
250
|
+
state.modal.results.appendChild(createEmptyState('支持中文关键词、英文词组和文档路径搜索'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
var terms = getQueryTerms(query);
|
|
255
|
+
var matches = searchDocuments(query);
|
|
256
|
+
if (matches.length === 0) {
|
|
257
|
+
setStatus('没有找到匹配的文档');
|
|
258
|
+
state.modal.results.appendChild(createEmptyState('换个关键词试试,或检查目录名称是否完整'));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
state.results = matches.slice(0, MAX_RESULTS);
|
|
263
|
+
setStatus(matches.length > MAX_RESULTS
|
|
264
|
+
? '找到 ' + matches.length + ' 篇文档,显示最相关的 ' + MAX_RESULTS + ' 篇'
|
|
265
|
+
: '找到 ' + matches.length + ' 篇文档');
|
|
266
|
+
state.results.forEach(function(result, index) {
|
|
267
|
+
state.modal.results.appendChild(createResult(result, index, terms));
|
|
268
|
+
});
|
|
269
|
+
setSelectedIndex(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createModal() {
|
|
273
|
+
var overlay = document.createElement('div');
|
|
274
|
+
overlay.className = 'doc-search-modal';
|
|
275
|
+
overlay.id = 'doc-search-modal';
|
|
276
|
+
overlay.hidden = true;
|
|
277
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
278
|
+
overlay.innerHTML = [
|
|
279
|
+
'<div class="doc-search-modal__panel" role="dialog" aria-modal="true" aria-labelledby="doc-search-title" aria-describedby="doc-search-status">',
|
|
280
|
+
'<div class="doc-search-modal__topline">',
|
|
281
|
+
'<span class="doc-search-modal__eyebrow" id="doc-search-title">搜索文档</span>',
|
|
282
|
+
'<button type="button" class="doc-search-modal__close" aria-label="关闭搜索">×</button>',
|
|
283
|
+
'</div>',
|
|
284
|
+
'<div class="doc-search-input-wrap">',
|
|
285
|
+
'<span class="doc-search-input-icon" aria-hidden="true">⌕</span>',
|
|
286
|
+
'<input class="doc-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="搜索标题、目录、正文或路径" aria-label="搜索文档" aria-controls="doc-search-results">',
|
|
287
|
+
'<kbd class="doc-search-input-key">Esc</kbd>',
|
|
288
|
+
'</div>',
|
|
289
|
+
'<div class="doc-search-status" id="doc-search-status" role="status" aria-live="polite"></div>',
|
|
290
|
+
'<div class="doc-search-results" id="doc-search-results" role="listbox" aria-label="搜索结果"></div>',
|
|
291
|
+
'<div class="doc-search-hint"><span>↑↓ 选择</span><span>Enter 打开</span><span>Esc 关闭</span></div>',
|
|
292
|
+
'</div>',
|
|
293
|
+
].join('');
|
|
294
|
+
|
|
295
|
+
var modal = {
|
|
296
|
+
overlay: overlay,
|
|
297
|
+
input: overlay.querySelector('.doc-search-input'),
|
|
298
|
+
status: overlay.querySelector('.doc-search-status'),
|
|
299
|
+
results: overlay.querySelector('.doc-search-results'),
|
|
300
|
+
close: overlay.querySelector('.doc-search-modal__close'),
|
|
301
|
+
};
|
|
302
|
+
modal.input.addEventListener('input', renderResults);
|
|
303
|
+
modal.close.addEventListener('click', closeSearch);
|
|
304
|
+
overlay.addEventListener('click', function(event) {
|
|
305
|
+
if (event.target === overlay) closeSearch();
|
|
306
|
+
});
|
|
307
|
+
document.body.appendChild(overlay);
|
|
308
|
+
state.modal = modal;
|
|
309
|
+
return modal;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function loadSearchIndex() {
|
|
313
|
+
if (state.index) return Promise.resolve(state.index);
|
|
314
|
+
if (state.indexPromise) return state.indexPromise;
|
|
315
|
+
|
|
316
|
+
state.indexPromise = fetch(getAssetUrl(SEARCH_INDEX_PATH), {
|
|
317
|
+
credentials: 'same-origin',
|
|
318
|
+
headers: { Accept: 'application/json' },
|
|
319
|
+
}).then(function(response) {
|
|
320
|
+
if (!response.ok) throw new Error('搜索索引请求失败:' + response.status);
|
|
321
|
+
return response.json();
|
|
322
|
+
}).then(function(payload) {
|
|
323
|
+
if (!payload || !Array.isArray(payload.documents)) {
|
|
324
|
+
throw new Error('搜索索引格式无效');
|
|
325
|
+
}
|
|
326
|
+
state.index = payload.documents.filter(function(documentData) {
|
|
327
|
+
return documentData && typeof documentData.path === 'string';
|
|
328
|
+
});
|
|
329
|
+
return state.index;
|
|
330
|
+
}).catch(function(error) {
|
|
331
|
+
state.indexPromise = null;
|
|
332
|
+
throw error;
|
|
333
|
+
});
|
|
334
|
+
return state.indexPromise;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function openSearch() {
|
|
338
|
+
if (!state.modal) createModal();
|
|
339
|
+
state.lastFocus = document.activeElement;
|
|
340
|
+
state.modal.overlay.hidden = false;
|
|
341
|
+
state.modal.overlay.setAttribute('aria-hidden', 'false');
|
|
342
|
+
document.body.classList.add('doc-search-open');
|
|
343
|
+
state.modal.input.disabled = true;
|
|
344
|
+
state.modal.input.value = '';
|
|
345
|
+
state.modal.results.textContent = '';
|
|
346
|
+
setStatus('正在加载文档索引…');
|
|
347
|
+
window.requestAnimationFrame(function() { state.modal.input.focus(); });
|
|
348
|
+
|
|
349
|
+
loadSearchIndex().then(function() {
|
|
350
|
+
if (state.modal.overlay.hidden) return;
|
|
351
|
+
state.modal.input.disabled = false;
|
|
352
|
+
renderResults();
|
|
353
|
+
state.modal.input.focus();
|
|
354
|
+
}).catch(function() {
|
|
355
|
+
if (state.modal.overlay.hidden) return;
|
|
356
|
+
state.modal.input.disabled = true;
|
|
357
|
+
setStatus('搜索索引加载失败,请刷新页面后重试');
|
|
358
|
+
state.modal.results.appendChild(createEmptyState('当前无法读取文档索引'));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function closeSearch() {
|
|
363
|
+
if (!state.modal || state.modal.overlay.hidden) return;
|
|
364
|
+
state.modal.overlay.hidden = true;
|
|
365
|
+
state.modal.overlay.setAttribute('aria-hidden', 'true');
|
|
366
|
+
document.body.classList.remove('doc-search-open');
|
|
367
|
+
if (state.lastFocus && typeof state.lastFocus.focus === 'function') state.lastFocus.focus();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function moveSelection(delta) {
|
|
371
|
+
if (!state.results.length) return;
|
|
372
|
+
setSelectedIndex(state.selectedIndex + delta);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function onKeyDown(event) {
|
|
376
|
+
if (event.isComposing) return;
|
|
377
|
+
if ((event.metaKey || event.ctrlKey) && String(event.key).toLowerCase() === 'k') {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
openSearch();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!state.modal || state.modal.overlay.hidden) return;
|
|
383
|
+
if (event.key === 'Escape') {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
closeSearch();
|
|
386
|
+
} else if (event.key === 'ArrowDown') {
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
moveSelection(1);
|
|
389
|
+
} else if (event.key === 'ArrowUp') {
|
|
390
|
+
event.preventDefault();
|
|
391
|
+
moveSelection(-1);
|
|
392
|
+
} else if (event.key === 'Enter' && state.selectedIndex >= 0 && state.results[state.selectedIndex]) {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
var selected = state.results[state.selectedIndex].document;
|
|
395
|
+
closeSearch();
|
|
396
|
+
var url = buildDocumentUrl(selected.path);
|
|
397
|
+
if (!isStaticSite() && typeof window.navigateToDoc === 'function') {
|
|
398
|
+
window.navigateToDoc(url);
|
|
399
|
+
} else {
|
|
400
|
+
window.location.href = url;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function init() {
|
|
406
|
+
var config = window.DOCNEST_CONFIG || {};
|
|
407
|
+
if (config.restrictedMode === true) return;
|
|
408
|
+
var trigger = document.getElementById('doc-search-trigger');
|
|
409
|
+
if (!trigger) return;
|
|
410
|
+
|
|
411
|
+
state.trigger = trigger;
|
|
412
|
+
trigger.addEventListener('click', openSearch);
|
|
413
|
+
var shortcut = trigger.querySelector('.doc-search-shortcut');
|
|
414
|
+
var isMac = /Mac|iPhone|iPad|iPod/i.test((window.navigator && window.navigator.platform) || '');
|
|
415
|
+
if (shortcut) shortcut.textContent = isMac ? '⌘ K' : 'Ctrl K';
|
|
416
|
+
document.addEventListener('keydown', onKeyDown);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (document.readyState === 'loading') {
|
|
420
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
421
|
+
} else {
|
|
422
|
+
init();
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
<div class="header-meta">
|
|
15
15
|
<div class="header-actions">
|
|
16
|
+
<% if (!docsRestrictedMode) { %>
|
|
17
|
+
<button type="button" id="doc-search-trigger" class="header-action-btn doc-search-trigger" aria-haspopup="dialog" aria-controls="doc-search-modal" title="搜索文档(⌘K / Ctrl K)">
|
|
18
|
+
<span class="doc-search-icon" aria-hidden="true">⌕</span>
|
|
19
|
+
<span class="header-action-text">搜索文档</span>
|
|
20
|
+
<kbd class="doc-search-shortcut" aria-label="打开搜索快捷键">⌘ K</kbd>
|
|
21
|
+
</button>
|
|
22
|
+
<% } %>
|
|
16
23
|
<% if (typeof showPrintButton !== 'undefined' && showPrintButton && !docsRestrictedMode) { %>
|
|
17
24
|
<button type="button" id="print-doc-btn" class="header-action-btn print-btn" title="打印当前文章">
|
|
18
25
|
<span class="print-icon">🖨️</span>
|
|
@@ -58,6 +65,9 @@
|
|
|
58
65
|
</div>
|
|
59
66
|
</div>
|
|
60
67
|
</header>
|
|
68
|
+
<% if (!docsRestrictedMode) { %>
|
|
69
|
+
<script src="/static/js/core/doc-search.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
|
|
70
|
+
<% } %>
|
|
61
71
|
<% if (docsAuthEnabled) { %>
|
|
62
72
|
<script src="/static/js/core/auth.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
|
|
63
73
|
<% } %>
|