cantip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- package/vite.config.ts +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sedokina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# cantip
|
|
2
|
+
|
|
3
|
+
A config-driven **Remix SSR documentation engine**. Ingest Obsidian vaults or
|
|
4
|
+
plain markdown folders and get a fast docs site with a persistent sidebar, tabs,
|
|
5
|
+
full-text search, dark/light theme, canvas rendering, and wikilinks — all driven
|
|
6
|
+
by a single `docs.config.ts`. ("Cantip" — кантип — is Kyrgyz for "how (to)".)
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm create cantip my-docs
|
|
12
|
+
cd my-docs
|
|
13
|
+
npm install
|
|
14
|
+
npm run dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or add to an existing project:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install cantip
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
…then create a `docs.config.ts` and run `npx cantip dev`.
|
|
24
|
+
|
|
25
|
+
## CLI
|
|
26
|
+
|
|
27
|
+
| Command | What |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `cantip generate` | Ingest sources + compile content from `docs.config.ts`. |
|
|
30
|
+
| `cantip dev` | Generate, then start the dev server. |
|
|
31
|
+
| `cantip build` | Generate, then build for production. |
|
|
32
|
+
| `cantip start` | Serve the production build. |
|
|
33
|
+
| `cantip typecheck` | Type-check the engine. |
|
|
34
|
+
|
|
35
|
+
## Configure
|
|
36
|
+
|
|
37
|
+
Everything lives in `docs.config.ts` (typed via `cantip/config`):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { defineConfig } from 'cantip/config'
|
|
41
|
+
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
site: { title: 'My Docs', lang: 'en', defaultTheme: 'dark' },
|
|
44
|
+
// Loose markdown in ./docs, served at the root:
|
|
45
|
+
general: { enabled: true, source: './docs' },
|
|
46
|
+
// …or named projects, each a folder / submodule / any path:
|
|
47
|
+
// projects: [{ id: 'guide', name: 'Guide', source: './content/guide' }],
|
|
48
|
+
// theme: { colors: { dark: { '--brand': 'oklch(0.7 0.2 250)' } } },
|
|
49
|
+
// components: { Home: './app/MyHome.tsx' },
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **Content sources** — submodule, loose folder, any path, or a `general` bucket
|
|
54
|
+
served at the root with no project concept.
|
|
55
|
+
- **Branding** — title, description, logos, favicon, language, default theme.
|
|
56
|
+
- **Theme** — `theme.colors` OKLCH tokens, no CSS edits.
|
|
57
|
+
- **Components** — swap `Home` / `DocPage` / `TopBar` / `Toc` for your own `.tsx`.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/** True when the app is currently in dark mode (the `.dark` class on <html>). */
|
|
4
|
+
function isDark(): boolean {
|
|
5
|
+
return document.documentElement.classList.contains('dark')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mounts any `[data-canvas-mount]` containers in the current page using the
|
|
10
|
+
* json-canvas-viewer library (loaded lazily, client-only). Re-runs whenever the
|
|
11
|
+
* given key changes (i.e. on each navigation) so freshly-rendered canvas pages
|
|
12
|
+
* get initialised. Ported 1:1 from the inline script in the old DocsLayout.astro.
|
|
13
|
+
*
|
|
14
|
+
* The viewer ships its own built-in `light`/`dark` palettes (background, cards,
|
|
15
|
+
* borders, dot grid, text) and defaults to `light`. We pass the current app
|
|
16
|
+
* theme on init and keep each mounted viewer in sync with the `.dark` class via
|
|
17
|
+
* a MutationObserver, so the canvas matches the rest of the site when the user
|
|
18
|
+
* toggles the theme.
|
|
19
|
+
*/
|
|
20
|
+
export default function CanvasMount({ deps }: { deps: string }) {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false
|
|
23
|
+
const viewers: Array<{ changeTheme: (theme?: 'dark' | 'light') => void }> = []
|
|
24
|
+
const containers = document.querySelectorAll<HTMLElement>('[data-canvas-mount]')
|
|
25
|
+
if (containers.length === 0) return
|
|
26
|
+
|
|
27
|
+
;(async () => {
|
|
28
|
+
const { JSONCanvasViewer, parser, Minimap, Controls } = await import('json-canvas-viewer')
|
|
29
|
+
if (cancelled) return
|
|
30
|
+
const theme = isDark() ? 'dark' : 'light'
|
|
31
|
+
containers.forEach((container) => {
|
|
32
|
+
const dataEl = container.querySelector<HTMLScriptElement>('script[type="application/json"]')
|
|
33
|
+
if (!dataEl) return
|
|
34
|
+
const canvas = JSON.parse(dataEl.textContent || '{"nodes":[],"edges":[]}')
|
|
35
|
+
container.removeAttribute('data-canvas-mount')
|
|
36
|
+
container.innerHTML = ''
|
|
37
|
+
const viewer = new JSONCanvasViewer({ container, canvas, parser, theme }, [
|
|
38
|
+
Minimap,
|
|
39
|
+
Controls,
|
|
40
|
+
])
|
|
41
|
+
viewers.push(viewer)
|
|
42
|
+
})
|
|
43
|
+
})()
|
|
44
|
+
|
|
45
|
+
// Re-theme mounted viewers whenever the app toggles its `.dark` class.
|
|
46
|
+
const observer = new MutationObserver(() => {
|
|
47
|
+
const theme = isDark() ? 'dark' : 'light'
|
|
48
|
+
viewers.forEach((viewer) => viewer.changeTheme(theme))
|
|
49
|
+
})
|
|
50
|
+
observer.observe(document.documentElement, {
|
|
51
|
+
attributes: true,
|
|
52
|
+
attributeFilter: ['class'],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true
|
|
57
|
+
observer.disconnect()
|
|
58
|
+
}
|
|
59
|
+
}, [deps])
|
|
60
|
+
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The document body HTML is rendered server-side and injected via
|
|
5
|
+
* `dangerouslySetInnerHTML`, so code blocks aren't React elements we can give a
|
|
6
|
+
* button declaratively. This component runs after each page render, wraps every
|
|
7
|
+
* `<pre>` in `.body` in a positioning shell, and injects a per-block toggle.
|
|
8
|
+
*
|
|
9
|
+
* Code blocks default to no-wrap with horizontal scroll; the toggle adds
|
|
10
|
+
* `.pre-wrap` to wrap long lines instead. The button lives on the shell (not
|
|
11
|
+
* inside the `<pre>`) so it stays pinned to the block's top-right corner and
|
|
12
|
+
* does not drift when the `<pre>` is scrolled horizontally.
|
|
13
|
+
*
|
|
14
|
+
* The preference is per-block and deliberately NOT persisted — it resets to
|
|
15
|
+
* no-wrap on reload/navigation. Re-runs whenever `deps` changes (pass the doc
|
|
16
|
+
* id) so freshly swapped-in content gets toggles too.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const WRAP_ICON =
|
|
20
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg>'
|
|
21
|
+
const NOWRAP_ICON =
|
|
22
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
|
|
23
|
+
|
|
24
|
+
const WRAP_LABEL = 'Переносить строки'
|
|
25
|
+
const NOWRAP_LABEL = 'Не переносить строки'
|
|
26
|
+
|
|
27
|
+
export function CodeWrapToggle({ deps }: { deps?: string }) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const blocks = document.querySelectorAll<HTMLPreElement>('.content .body pre')
|
|
30
|
+
const cleanups: Array<() => void> = []
|
|
31
|
+
|
|
32
|
+
blocks.forEach((pre) => {
|
|
33
|
+
// Guard against double-wrapping if the effect re-runs over DOM that
|
|
34
|
+
// React hasn't replaced (e.g. same content remounting).
|
|
35
|
+
if (pre.parentElement?.classList.contains('pre-wrap-shell')) return
|
|
36
|
+
|
|
37
|
+
const shell = document.createElement('div')
|
|
38
|
+
shell.className = 'pre-wrap-shell'
|
|
39
|
+
pre.replaceWith(shell)
|
|
40
|
+
shell.appendChild(pre)
|
|
41
|
+
|
|
42
|
+
const button = document.createElement('button')
|
|
43
|
+
button.type = 'button'
|
|
44
|
+
button.className = 'pre-wrap-toggle'
|
|
45
|
+
|
|
46
|
+
const sync = () => {
|
|
47
|
+
const wrapped = pre.classList.contains('pre-wrap')
|
|
48
|
+
// Icon shows the action the click performs (the OTHER state).
|
|
49
|
+
button.innerHTML = wrapped ? NOWRAP_ICON : WRAP_ICON
|
|
50
|
+
const label = wrapped ? NOWRAP_LABEL : WRAP_LABEL
|
|
51
|
+
button.setAttribute('aria-label', label)
|
|
52
|
+
button.title = label
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const onClick = () => {
|
|
56
|
+
pre.classList.toggle('pre-wrap')
|
|
57
|
+
sync()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
button.addEventListener('click', onClick)
|
|
61
|
+
sync()
|
|
62
|
+
shell.appendChild(button)
|
|
63
|
+
|
|
64
|
+
cleanups.push(() => {
|
|
65
|
+
button.removeEventListener('click', onClick)
|
|
66
|
+
// Unwrap: move the <pre> back out and drop the shell.
|
|
67
|
+
if (shell.parentElement) {
|
|
68
|
+
shell.replaceWith(pre)
|
|
69
|
+
}
|
|
70
|
+
button.remove()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return () => cleanups.forEach((fn) => fn())
|
|
75
|
+
}, [deps])
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronUp, Search as SearchIcon, X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { cn } from '~/lib/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-page "find on this page" — a built-in replacement for the browser's Ctrl+F,
|
|
8
|
+
* aimed at mobile where the native find bar is awful (tiny, covers content, hard
|
|
9
|
+
* to dismiss). Searches only the article body (`article.content`), highlights
|
|
10
|
+
* every match, and lets the reader step through them with a running "3 / 12"
|
|
11
|
+
* count.
|
|
12
|
+
*
|
|
13
|
+
* Highlighting uses the CSS Custom Highlight API (`CSS.highlights` + the
|
|
14
|
+
* `::highlight()` pseudo, styled in app.css). That paints over Range objects
|
|
15
|
+
* WITHOUT mutating the DOM — essential here because the article HTML is injected
|
|
16
|
+
* via dangerouslySetInnerHTML and React owns nothing inside it; wrapping matches
|
|
17
|
+
* in <mark> would fight React and risk corrupting the content. Ranges are also
|
|
18
|
+
* cheap to discard, so clearing on close is a one-liner.
|
|
19
|
+
*
|
|
20
|
+
* Rendered (mobile-only) by the doc route; opened from the page floating menu.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Highlight registry names — two layers so the current match reads differently. */
|
|
24
|
+
const HL_ALL = 'find-all'
|
|
25
|
+
const HL_CURRENT = 'find-current'
|
|
26
|
+
|
|
27
|
+
/** Whether the browser supports the CSS Custom Highlight API we rely on. */
|
|
28
|
+
function highlightSupported(): boolean {
|
|
29
|
+
return typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Drop both highlight layers from the global registry. */
|
|
33
|
+
function clearHighlights() {
|
|
34
|
+
if (!highlightSupported()) return
|
|
35
|
+
CSS.highlights.delete(HL_ALL)
|
|
36
|
+
CSS.highlights.delete(HL_CURRENT)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect every text node under `root`, skipping ones inside <script>/<style>
|
|
41
|
+
* (never visible prose). Each node's full text is searched as one string.
|
|
42
|
+
*/
|
|
43
|
+
function textNodesIn(root: Node): Text[] {
|
|
44
|
+
const nodes: Text[] = []
|
|
45
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
46
|
+
acceptNode(node) {
|
|
47
|
+
const parent = node.parentElement
|
|
48
|
+
if (!parent) return NodeFilter.FILTER_REJECT
|
|
49
|
+
const tag = parent.tagName
|
|
50
|
+
if (tag === 'SCRIPT' || tag === 'STYLE') return NodeFilter.FILTER_REJECT
|
|
51
|
+
// Skip pure-whitespace nodes — nothing matchable, keeps the walk lean.
|
|
52
|
+
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
|
|
53
|
+
return NodeFilter.FILTER_ACCEPT
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
let n = walker.nextNode()
|
|
57
|
+
while (n) {
|
|
58
|
+
nodes.push(n as Text)
|
|
59
|
+
n = walker.nextNode()
|
|
60
|
+
}
|
|
61
|
+
return nodes
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find every (case-insensitive) occurrence of `query` within the article and
|
|
66
|
+
* return a Range per match. Matches are confined to single text nodes — a query
|
|
67
|
+
* spanning element boundaries (e.g. across a <strong>) won't match, which is the
|
|
68
|
+
* normal, acceptable limitation for this kind of find.
|
|
69
|
+
*/
|
|
70
|
+
function findRanges(root: Element, query: string): Range[] {
|
|
71
|
+
const ranges: Range[] = []
|
|
72
|
+
if (!query) return ranges
|
|
73
|
+
const needle = query.toLowerCase()
|
|
74
|
+
for (const node of textNodesIn(root)) {
|
|
75
|
+
const haystack = (node.nodeValue ?? '').toLowerCase()
|
|
76
|
+
let from = 0
|
|
77
|
+
let idx = haystack.indexOf(needle, from)
|
|
78
|
+
while (idx !== -1) {
|
|
79
|
+
const range = document.createRange()
|
|
80
|
+
range.setStart(node, idx)
|
|
81
|
+
range.setEnd(node, idx + needle.length)
|
|
82
|
+
ranges.push(range)
|
|
83
|
+
from = idx + needle.length
|
|
84
|
+
idx = haystack.indexOf(needle, from)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return ranges
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default function FindOnPage({ onClose }: { onClose: () => void }) {
|
|
91
|
+
const [query, setQuery] = useState('')
|
|
92
|
+
const [ranges, setRanges] = useState<Range[]>([])
|
|
93
|
+
const [current, setCurrent] = useState(0)
|
|
94
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
95
|
+
|
|
96
|
+
const supported = useMemo(highlightSupported, [])
|
|
97
|
+
|
|
98
|
+
// Focus the field on open so the reader can type straight away.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
inputRef.current?.focus()
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
// Recompute matches whenever the query changes. Reset to the first match each
|
|
104
|
+
// time so the count + current highlight stay in sync with what's typed.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!supported) return
|
|
107
|
+
const root = document.querySelector('article.content')
|
|
108
|
+
const q = query.trim()
|
|
109
|
+
const found = root && q ? findRanges(root, q) : []
|
|
110
|
+
setRanges(found)
|
|
111
|
+
setCurrent(0)
|
|
112
|
+
}, [query, supported])
|
|
113
|
+
|
|
114
|
+
// Paint the "all matches" layer whenever the match set changes, and the
|
|
115
|
+
// "current match" layer whenever the set OR the cursor changes. Two separate
|
|
116
|
+
// Highlight objects so the active match can be styled distinctly.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!supported) return
|
|
119
|
+
if (ranges.length === 0) {
|
|
120
|
+
clearHighlights()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
CSS.highlights.set(HL_ALL, new Highlight(...ranges))
|
|
124
|
+
const active = ranges[current]
|
|
125
|
+
if (active) CSS.highlights.set(HL_CURRENT, new Highlight(active))
|
|
126
|
+
else CSS.highlights.delete(HL_CURRENT)
|
|
127
|
+
}, [ranges, current, supported])
|
|
128
|
+
|
|
129
|
+
// Scroll the active match into view as the reader steps through.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const active = ranges[current]
|
|
132
|
+
if (!active) return
|
|
133
|
+
// Range has no scrollIntoView; use its bounding rect's nearest element.
|
|
134
|
+
const target =
|
|
135
|
+
active.startContainer.parentElement ?? (active.commonAncestorContainer as Element | null)
|
|
136
|
+
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
137
|
+
}, [ranges, current])
|
|
138
|
+
|
|
139
|
+
// Always clear highlights when the bar unmounts (close), so matches don't
|
|
140
|
+
// linger painted over the page after the reader is done.
|
|
141
|
+
useEffect(() => clearHighlights, [])
|
|
142
|
+
|
|
143
|
+
const step = useCallback(
|
|
144
|
+
(delta: number) => {
|
|
145
|
+
setCurrent((c) => {
|
|
146
|
+
if (ranges.length === 0) return 0
|
|
147
|
+
// Wrap around both ends so prev from the first lands on the last.
|
|
148
|
+
return (c + delta + ranges.length) % ranges.length
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
[ranges.length],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
155
|
+
if (e.key === 'Enter') {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
step(e.shiftKey ? -1 : 1)
|
|
158
|
+
} else if (e.key === 'Escape') {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
onClose()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const has = ranges.length > 0
|
|
165
|
+
const q = query.trim()
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={cn(
|
|
170
|
+
'fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] z-[110] md:hidden',
|
|
171
|
+
'flex items-center gap-1 rounded-2xl border bg-sidebar/95 px-2 py-1.5 shadow-lg backdrop-blur',
|
|
172
|
+
)}
|
|
173
|
+
role="search"
|
|
174
|
+
aria-label="Найти на странице"
|
|
175
|
+
>
|
|
176
|
+
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
177
|
+
<input
|
|
178
|
+
ref={inputRef}
|
|
179
|
+
value={query}
|
|
180
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
181
|
+
onKeyDown={onKeyDown}
|
|
182
|
+
placeholder="Найти на странице…"
|
|
183
|
+
className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
184
|
+
autoComplete="off"
|
|
185
|
+
spellCheck={false}
|
|
186
|
+
enterKeyHint="search"
|
|
187
|
+
/>
|
|
188
|
+
|
|
189
|
+
{/* Match counter: "3 / 12", or "0" when nothing matches a non-empty query. */}
|
|
190
|
+
{q && (
|
|
191
|
+
<span className="shrink-0 px-1 text-xs tabular-nums text-muted-foreground">
|
|
192
|
+
{has ? `${current + 1} / ${ranges.length}` : '0'}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => step(-1)}
|
|
199
|
+
disabled={!has}
|
|
200
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
201
|
+
aria-label="Предыдущее совпадение"
|
|
202
|
+
>
|
|
203
|
+
<ChevronUp className="size-4" />
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => step(1)}
|
|
208
|
+
disabled={!has}
|
|
209
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
210
|
+
aria-label="Следующее совпадение"
|
|
211
|
+
>
|
|
212
|
+
<ChevronDown className="size-4" />
|
|
213
|
+
</button>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={onClose}
|
|
217
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
218
|
+
aria-label="Закрыть поиск по странице"
|
|
219
|
+
>
|
|
220
|
+
<X className="size-4" />
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Home, FolderTree, LayoutGrid, Search as SearchIcon } from 'lucide-react'
|
|
2
|
+
import { Link } from '@remix-run/react'
|
|
3
|
+
|
|
4
|
+
import { Search } from '~/components/Search'
|
|
5
|
+
import { t } from '~/lib/site'
|
|
6
|
+
import { cn } from '~/lib/utils'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Whether the directory (sidebar) overlay is open — drives the tab's active state. */
|
|
10
|
+
dirsOpen: boolean
|
|
11
|
+
/** Whether the fullscreen projects panel is open. */
|
|
12
|
+
projectsOpen: boolean
|
|
13
|
+
/** Whether there's a file tree to open. False with no active project (e.g. `/`),
|
|
14
|
+
* where the Files tab is disabled since there's nothing to show. */
|
|
15
|
+
filesEnabled: boolean
|
|
16
|
+
onToggleDirs: () => void
|
|
17
|
+
onToggleProjects: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Shared classes for a tab: stacked icon + label, active = foreground colour. */
|
|
21
|
+
const tab =
|
|
22
|
+
'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-lg py-1.5 text-[0.625rem] font-medium text-muted-foreground transition-colors'
|
|
23
|
+
const tabActive = 'text-foreground'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Classic mobile floating bottom navigation: a detached, rounded pill anchored
|
|
27
|
+
* above the bottom safe area. Four tabs, in order — Home (link to `/`), Projects
|
|
28
|
+
* (the fullscreen project switcher, which also houses the theme toggle), Files
|
|
29
|
+
* (the desktop sidebar tree, opened as a mobile overlay), and Search (reuses the
|
|
30
|
+
* Pagefind Search modal). No standalone theme button — theme lives inside Projects.
|
|
31
|
+
*
|
|
32
|
+
* Only the floating-bar chrome lives here; the panels it toggles (sidebar overlay,
|
|
33
|
+
* projects panel) are rendered by the root so they can sit behind this bar.
|
|
34
|
+
*/
|
|
35
|
+
export default function MobileBottomBar({
|
|
36
|
+
dirsOpen,
|
|
37
|
+
projectsOpen,
|
|
38
|
+
filesEnabled,
|
|
39
|
+
onToggleDirs,
|
|
40
|
+
onToggleProjects,
|
|
41
|
+
}: Props) {
|
|
42
|
+
return (
|
|
43
|
+
<nav
|
|
44
|
+
className={cn(
|
|
45
|
+
'fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] z-100 md:hidden',
|
|
46
|
+
'flex items-stretch gap-1 rounded-2xl border bg-sidebar/95 px-1.5 py-1 shadow-lg backdrop-blur',
|
|
47
|
+
)}
|
|
48
|
+
aria-label="Навигация"
|
|
49
|
+
>
|
|
50
|
+
<Link to="/" className={tab} aria-label={t('home')}>
|
|
51
|
+
<Home className="size-5" />
|
|
52
|
+
<span>{t('home')}</span>
|
|
53
|
+
</Link>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={onToggleProjects}
|
|
58
|
+
className={cn(tab, projectsOpen && tabActive)}
|
|
59
|
+
aria-label={t('projects')}
|
|
60
|
+
aria-expanded={projectsOpen}
|
|
61
|
+
>
|
|
62
|
+
<LayoutGrid className="size-5" />
|
|
63
|
+
<span>{t('projects')}</span>
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={onToggleDirs}
|
|
69
|
+
disabled={!filesEnabled}
|
|
70
|
+
className={cn(tab, dirsOpen && tabActive, 'disabled:pointer-events-none disabled:opacity-40')}
|
|
71
|
+
aria-label={t('files')}
|
|
72
|
+
aria-expanded={dirsOpen}
|
|
73
|
+
>
|
|
74
|
+
<FolderTree className="size-5" />
|
|
75
|
+
<span>{t('files')}</span>
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{/* Reuse the Pagefind Search modal, but render our own stacked tab as its
|
|
79
|
+
trigger so it matches the other tabs (icon over label). The desktop
|
|
80
|
+
TopBar's instance owns ⌘K, so disable the shortcut here to avoid
|
|
81
|
+
double-opening. */}
|
|
82
|
+
<Search
|
|
83
|
+
enableShortcut={false}
|
|
84
|
+
trigger={(open) => (
|
|
85
|
+
<button type="button" onClick={open} className={tab} aria-label={t('search')}>
|
|
86
|
+
<SearchIcon className="size-5" />
|
|
87
|
+
<span>{t('search')}</span>
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
91
|
+
</nav>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useNavigate } from '@remix-run/react'
|
|
3
|
+
import { Check, X } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { getProjects, getProject, type Project } from '~/lib/projects'
|
|
6
|
+
import { ThemeToggle } from '~/components/theme-toggle'
|
|
7
|
+
import { t } from '~/lib/site'
|
|
8
|
+
import { cn } from '~/lib/utils'
|
|
9
|
+
|
|
10
|
+
/** Logo sized via inline style to win the unlayered `img{height:auto}` cascade. */
|
|
11
|
+
function ProjectLogo({ project }: { project: Project }) {
|
|
12
|
+
return (
|
|
13
|
+
<img
|
|
14
|
+
src={project.logo}
|
|
15
|
+
alt=""
|
|
16
|
+
aria-hidden
|
|
17
|
+
width={24}
|
|
18
|
+
height={24}
|
|
19
|
+
style={{ height: 24, width: 24 }}
|
|
20
|
+
className="shrink-0 rounded"
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Id of the active project, or null when none is selected (e.g. `/`). */
|
|
27
|
+
activeId: string | null
|
|
28
|
+
open: boolean
|
|
29
|
+
onClose: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fullscreen mobile "side menu": the project list rendered as tappable items
|
|
34
|
+
* (not a dropdown), the active one ticked, plus a theme-toggle row at the bottom.
|
|
35
|
+
* Picking a project navigates to its landing doc — the root loader then re-derives
|
|
36
|
+
* the active project and swaps the sidebar. Lives above the floating bottom bar
|
|
37
|
+
* (which stays visible) so the Projects tab can re-tap to close. Self-contained
|
|
38
|
+
* Escape + body-scroll-lock, mirroring the Search/ProjectSwitcher pattern.
|
|
39
|
+
*/
|
|
40
|
+
export default function MobileProjectsPanel({ activeId, open, onClose }: Props) {
|
|
41
|
+
const navigate = useNavigate()
|
|
42
|
+
const projects = getProjects()
|
|
43
|
+
const active = activeId ? (getProject(activeId) ?? null) : null
|
|
44
|
+
|
|
45
|
+
// Escape closes; lock background scroll while open.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return
|
|
48
|
+
const onKey = (e: KeyboardEvent) => {
|
|
49
|
+
if (e.key === 'Escape') onClose()
|
|
50
|
+
}
|
|
51
|
+
document.addEventListener('keydown', onKey)
|
|
52
|
+
const prev = document.body.style.overflow
|
|
53
|
+
document.body.style.overflow = 'hidden'
|
|
54
|
+
return () => {
|
|
55
|
+
document.removeEventListener('keydown', onKey)
|
|
56
|
+
document.body.style.overflow = prev
|
|
57
|
+
}
|
|
58
|
+
}, [open, onClose])
|
|
59
|
+
|
|
60
|
+
if (!open) return null
|
|
61
|
+
|
|
62
|
+
const select = (p: Project) => {
|
|
63
|
+
onClose()
|
|
64
|
+
if (p.id !== active?.id) navigate(p.landing)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
// Sits below the floating bar (bottom inset clears it) so the bar's Projects
|
|
69
|
+
// tab stays tappable to toggle the panel shut.
|
|
70
|
+
<div className="fixed inset-x-0 bottom-0 top-0 z-90 flex flex-col bg-popover pb-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)] md:hidden">
|
|
71
|
+
<div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
|
|
72
|
+
<span className="text-sm font-semibold text-foreground">{t('projects')}</span>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={onClose}
|
|
76
|
+
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
77
|
+
aria-label={t('close')}
|
|
78
|
+
>
|
|
79
|
+
<X className="size-5" />
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<ul className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
84
|
+
{projects.map((p) => {
|
|
85
|
+
const isActive = p.id === active?.id
|
|
86
|
+
return (
|
|
87
|
+
<li key={p.id}>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => select(p)}
|
|
91
|
+
className={cn(
|
|
92
|
+
'flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left',
|
|
93
|
+
'transition-colors hover:bg-sidebar-accent',
|
|
94
|
+
isActive && 'bg-sidebar-accent',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<ProjectLogo project={p} />
|
|
98
|
+
<span className="min-w-0 flex-1 truncate text-base text-foreground">{p.name}</span>
|
|
99
|
+
{isActive && <Check className="size-5 shrink-0 text-foreground" />}
|
|
100
|
+
</button>
|
|
101
|
+
</li>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
</ul>
|
|
105
|
+
|
|
106
|
+
{/* Theme toggle row — a normal "settings" row at the bottom of the menu. */}
|
|
107
|
+
<div className="flex shrink-0 items-center justify-between border-t px-4 py-3">
|
|
108
|
+
<span className="text-sm text-foreground">Тема оформления</span>
|
|
109
|
+
<ThemeToggle className="text-muted-foreground" />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|