create-zudo-doc 0.2.0 → 0.2.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.
- package/dist/api.js +4 -1
- package/dist/cli.js +4 -6
- package/dist/preset.js +11 -0
- package/dist/prompts.js +2 -6
- package/dist/scaffold.js +15 -9
- package/dist/settings-gen.js +7 -7
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +25 -0
- package/dist/zfb-config-gen.js +11 -50
- package/package.json +1 -1
- package/templates/base/pages/_data.ts +10 -23
- package/templates/base/pages/docs/[[...slug]].tsx +27 -168
- package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
- package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
- package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
- package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
- package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
- package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
- package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
- package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
- package/templates/base/pages/lib/_header-with-defaults.tsx +51 -89
- package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
- package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
- package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
- package/templates/base/pages/lib/_search-widget-script.ts +32 -9
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
- package/templates/base/pages/lib/locale-merge.ts +1 -1
- package/templates/base/pages/lib/route-enumerators.ts +11 -7
- package/templates/base/plugins/connect-adapter.mjs +30 -1
- package/templates/base/plugins/copy-public-plugin.mjs +10 -2
- package/templates/base/plugins/search-index-plugin.mjs +20 -8
- package/templates/base/src/components/sidebar-toggle.tsx +1 -1
- package/templates/base/src/components/sidebar-tree.tsx +10 -4
- package/templates/base/src/config/color-schemes.ts +4 -0
- package/templates/base/src/config/docs-schema.ts +94 -0
- package/templates/base/src/config/i18n.ts +10 -3
- package/templates/base/src/styles/global.css +14 -0
- package/templates/base/src/types/docs-entry.ts +8 -26
- package/templates/base/src/utils/base.ts +5 -3
- package/templates/base/src/utils/docs.ts +144 -169
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
- package/templates/features/docHistory/files/src/components/doc-history.tsx +28 -8
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
- package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
- package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
- package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
- package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
- package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
- package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
- package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
- package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
- package/templates/base/src/components/content/heading-h3.tsx +0 -20
- package/templates/base/src/components/theme-toggle.tsx +0 -107
- package/templates/base/src/hooks/use-active-heading.ts +0 -133
- package/templates/base/src/plugins/docs-source-map.ts +0 -103
- package/templates/base/src/plugins/hast-utils.ts +0 -10
- package/templates/base/src/plugins/rehype-code-title.ts +0 -50
- package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
- package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
- package/templates/base/src/plugins/url-utils.ts +0 -4
- package/templates/base/src/utils/dedent.ts +0 -24
- package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
- package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { Root, Element } from "hast";
|
|
2
|
-
import GithubSlugger from "github-slugger";
|
|
3
|
-
import { visit } from "unist-util-visit";
|
|
4
|
-
import { extractText } from "./hast-utils";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Rehype plugin that adds Docusaurus-style anchor links to headings (h2-h6).
|
|
8
|
-
*
|
|
9
|
-
* Generates heading IDs (via github-slugger) and appends:
|
|
10
|
-
* <a href="#id" class="hash-link" aria-label="Direct link to ..."></a>
|
|
11
|
-
*
|
|
12
|
-
* The "#" symbol is rendered via CSS ::after to avoid polluting
|
|
13
|
-
* heading text extraction (used for TOC).
|
|
14
|
-
*
|
|
15
|
-
* Sets heading IDs itself (via github-slugger); skips headings that already
|
|
16
|
-
* have an ID assigned upstream.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const headingTags = new Set(["h2", "h3", "h4", "h5", "h6"]);
|
|
20
|
-
|
|
21
|
-
export function rehypeHeadingLinks() {
|
|
22
|
-
return (tree: Root) => {
|
|
23
|
-
const slugger = new GithubSlugger();
|
|
24
|
-
|
|
25
|
-
visit(tree, "element", (node: Element) => {
|
|
26
|
-
if (!headingTags.has(node.tagName)) return;
|
|
27
|
-
|
|
28
|
-
const text = node.children
|
|
29
|
-
.map((c) => extractText(c))
|
|
30
|
-
.join("");
|
|
31
|
-
|
|
32
|
-
const id =
|
|
33
|
-
(node.properties?.id as string | undefined) || slugger.slug(text);
|
|
34
|
-
|
|
35
|
-
// Set the id if not already present
|
|
36
|
-
if (!node.properties) node.properties = {};
|
|
37
|
-
if (!node.properties.id) node.properties.id = id;
|
|
38
|
-
|
|
39
|
-
const link: Element = {
|
|
40
|
-
type: "element",
|
|
41
|
-
tagName: "a",
|
|
42
|
-
properties: {
|
|
43
|
-
href: `#${id}`,
|
|
44
|
-
className: ["hash-link"],
|
|
45
|
-
"aria-label": `Direct link to ${text}`,
|
|
46
|
-
},
|
|
47
|
-
children: [],
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
node.children.push(link);
|
|
51
|
-
});
|
|
52
|
-
};
|
|
53
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { Root, Element } from "hast";
|
|
2
|
-
import { visit } from "unist-util-visit";
|
|
3
|
-
import { extractText } from "./hast-utils";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Rehype plugin that transforms mermaid code blocks into renderable containers.
|
|
7
|
-
*
|
|
8
|
-
* After Shiki processes code blocks, mermaid blocks become:
|
|
9
|
-
* <pre data-language="mermaid"><code><span>...</span></code></pre>
|
|
10
|
-
*
|
|
11
|
-
* This plugin converts them to:
|
|
12
|
-
* <div class="mermaid" data-mermaid>graph LR; A-->B</div>
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export function rehypeMermaid() {
|
|
16
|
-
return (tree: Root) => {
|
|
17
|
-
visit(tree, "element", (node: Element, index, parent) => {
|
|
18
|
-
if (
|
|
19
|
-
node.tagName !== "pre" ||
|
|
20
|
-
!parent ||
|
|
21
|
-
index === undefined
|
|
22
|
-
) return;
|
|
23
|
-
|
|
24
|
-
// Match Shiki-processed mermaid blocks (data-language="mermaid")
|
|
25
|
-
if (node.properties?.dataLanguage !== "mermaid") return;
|
|
26
|
-
|
|
27
|
-
// Extract all text content recursively from the code/span tree
|
|
28
|
-
const text = node.children
|
|
29
|
-
.map((c) => extractText(c))
|
|
30
|
-
.join("");
|
|
31
|
-
|
|
32
|
-
// Replace the <pre> with a <div class="mermaid">
|
|
33
|
-
(parent as Element).children[index] = {
|
|
34
|
-
type: "element",
|
|
35
|
-
tagName: "div",
|
|
36
|
-
properties: { className: ["mermaid"], "data-mermaid": true },
|
|
37
|
-
children: [{ type: "text", value: text }],
|
|
38
|
-
};
|
|
39
|
-
});
|
|
40
|
-
};
|
|
41
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strip common leading whitespace from all lines of a template literal string.
|
|
3
|
-
* Similar to Python's textwrap.dedent().
|
|
4
|
-
*/
|
|
5
|
-
export function dedent(text: string): string {
|
|
6
|
-
const lines = text.split('\n');
|
|
7
|
-
|
|
8
|
-
// Find minimum indentation (ignoring empty/whitespace-only lines)
|
|
9
|
-
let minIndent = Infinity;
|
|
10
|
-
for (const line of lines) {
|
|
11
|
-
if (line.trim().length === 0) continue;
|
|
12
|
-
const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
13
|
-
if (indent < minIndent) minIndent = indent;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
if (minIndent === 0 || minIndent === Infinity) {
|
|
17
|
-
return text.trim();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return lines
|
|
21
|
-
.map((line) => (line.trim().length === 0 ? '' : line.slice(minIndent)))
|
|
22
|
-
.join('\n')
|
|
23
|
-
.trim();
|
|
24
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import type { DocHistoryEntry, DocHistoryData } from "@/types/doc-history";
|
|
4
|
-
import { collectMdFiles } from "./content-files";
|
|
5
|
-
|
|
6
|
-
/** Shared options to suppress git stderr noise */
|
|
7
|
-
const QUIET: { encoding: "utf-8"; stdio: ["pipe", "pipe", "pipe"] } = {
|
|
8
|
-
encoding: "utf-8",
|
|
9
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
/** Cache the repo root to avoid repeated git calls */
|
|
13
|
-
let repoRootCache: string | null = null;
|
|
14
|
-
|
|
15
|
-
function getRepoRoot(): string {
|
|
16
|
-
if (repoRootCache) return repoRootCache;
|
|
17
|
-
repoRootCache = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
18
|
-
encoding: "utf-8",
|
|
19
|
-
}).trim();
|
|
20
|
-
return repoRootCache;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Convert an absolute path to a repo-relative path for git commands */
|
|
24
|
-
function toRepoRelative(absolutePath: string): string {
|
|
25
|
-
return path.relative(getRepoRoot(), absolutePath);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Get the list of commit hashes that touched a file, newest first.
|
|
30
|
-
* Uses --follow to track renames.
|
|
31
|
-
* Limits to maxEntries commits (default 50).
|
|
32
|
-
*/
|
|
33
|
-
export function getFileCommits(
|
|
34
|
-
filePath: string,
|
|
35
|
-
maxEntries = 50,
|
|
36
|
-
): string[] {
|
|
37
|
-
try {
|
|
38
|
-
const output = execFileSync(
|
|
39
|
-
"git",
|
|
40
|
-
[
|
|
41
|
-
"log",
|
|
42
|
-
"--follow",
|
|
43
|
-
"--format=%H",
|
|
44
|
-
"-n",
|
|
45
|
-
String(maxEntries),
|
|
46
|
-
"--",
|
|
47
|
-
filePath,
|
|
48
|
-
],
|
|
49
|
-
QUIET,
|
|
50
|
-
).trim();
|
|
51
|
-
return output ? [...new Set(output.split("\n"))] : [];
|
|
52
|
-
} catch {
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get metadata for a specific commit on a file.
|
|
59
|
-
* Returns { hash, date, author, message } with full hash for unique identification.
|
|
60
|
-
*/
|
|
61
|
-
export function getCommitInfo(
|
|
62
|
-
hash: string,
|
|
63
|
-
filePath: string,
|
|
64
|
-
): Omit<DocHistoryEntry, "content"> {
|
|
65
|
-
try {
|
|
66
|
-
const output = execFileSync(
|
|
67
|
-
"git",
|
|
68
|
-
["log", "-1", "--format=%H%n%aI%n%aN%n%s", hash, "--", filePath],
|
|
69
|
-
QUIET,
|
|
70
|
-
).trim();
|
|
71
|
-
const lines = output.split("\n");
|
|
72
|
-
return {
|
|
73
|
-
hash: lines[0] ?? hash,
|
|
74
|
-
date: lines[1] ?? "",
|
|
75
|
-
author: lines[2] ?? "",
|
|
76
|
-
message: lines[3] ?? "",
|
|
77
|
-
};
|
|
78
|
-
} catch {
|
|
79
|
-
return { hash, date: "", author: "", message: "" };
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Get the file content at a specific commit.
|
|
85
|
-
* Accepts absolute paths and converts to repo-relative for git show.
|
|
86
|
-
* Handles renamed files by falling back to the old path via git log --follow.
|
|
87
|
-
*/
|
|
88
|
-
export function getFileAtCommit(hash: string, filePath: string): string {
|
|
89
|
-
const relPath = path.isAbsolute(filePath)
|
|
90
|
-
? toRepoRelative(filePath)
|
|
91
|
-
: filePath;
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
return execFileSync("git", ["show", `${hash}:${relPath}`], QUIET);
|
|
95
|
-
} catch {
|
|
96
|
-
// File may have been renamed — find the old path at this commit
|
|
97
|
-
try {
|
|
98
|
-
const oldPath = execFileSync(
|
|
99
|
-
"git",
|
|
100
|
-
[
|
|
101
|
-
"log",
|
|
102
|
-
"-1",
|
|
103
|
-
"--follow",
|
|
104
|
-
"--diff-filter=R",
|
|
105
|
-
"--format=",
|
|
106
|
-
"--name-only",
|
|
107
|
-
hash,
|
|
108
|
-
"--",
|
|
109
|
-
relPath,
|
|
110
|
-
],
|
|
111
|
-
QUIET,
|
|
112
|
-
).trim();
|
|
113
|
-
if (oldPath) {
|
|
114
|
-
return execFileSync("git", ["show", `${hash}:${oldPath}`], QUIET);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// ignore
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Last resort: use git log --follow to find the path at this revision
|
|
121
|
-
try {
|
|
122
|
-
const followOutput = execFileSync(
|
|
123
|
-
"git",
|
|
124
|
-
[
|
|
125
|
-
"log",
|
|
126
|
-
"--follow",
|
|
127
|
-
"--format=%H",
|
|
128
|
-
"--name-only",
|
|
129
|
-
"--diff-filter=AMRC",
|
|
130
|
-
"--",
|
|
131
|
-
relPath,
|
|
132
|
-
],
|
|
133
|
-
QUIET,
|
|
134
|
-
).trim();
|
|
135
|
-
const lines = followOutput.split("\n").filter(Boolean);
|
|
136
|
-
// Lines alternate: hash, filename, hash, filename...
|
|
137
|
-
for (let i = 0; i < lines.length - 1; i += 2) {
|
|
138
|
-
if (lines[i] === hash && lines[i + 1]) {
|
|
139
|
-
return execFileSync(
|
|
140
|
-
"git",
|
|
141
|
-
["show", `${hash}:${lines[i + 1]}`],
|
|
142
|
-
QUIET,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
// ignore
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return "";
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Get the complete history for a document file.
|
|
156
|
-
* Returns DocHistoryData with all entries populated.
|
|
157
|
-
*/
|
|
158
|
-
export function getDocHistory(
|
|
159
|
-
filePath: string,
|
|
160
|
-
slug: string,
|
|
161
|
-
maxEntries = 50,
|
|
162
|
-
): DocHistoryData {
|
|
163
|
-
const commits = getFileCommits(filePath, maxEntries);
|
|
164
|
-
const entries: DocHistoryEntry[] = commits.map((hash) => {
|
|
165
|
-
const info = getCommitInfo(hash, filePath);
|
|
166
|
-
const content = getFileAtCommit(hash, filePath);
|
|
167
|
-
return { ...info, content };
|
|
168
|
-
});
|
|
169
|
-
return { slug, filePath, entries };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Collect all MDX/md files in a content directory.
|
|
174
|
-
* Delegates to the shared collectMdFiles utility from content-files.ts.
|
|
175
|
-
*/
|
|
176
|
-
export function collectContentFiles(
|
|
177
|
-
contentDir: string,
|
|
178
|
-
): Array<{ filePath: string; slug: string }> {
|
|
179
|
-
return collectMdFiles(contentDir);
|
|
180
|
-
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
export function initSidebarResizer() {
|
|
2
|
-
const sidebar = document.getElementById("desktop-sidebar");
|
|
3
|
-
if (!sidebar || sidebar.querySelector("[data-sidebar-resizer]")) return;
|
|
4
|
-
// Only attach to the real fixed desktop panel. On hide_sidebar pages the
|
|
5
|
-
// aside renders sr-only (position:absolute) purely for the ARIA landmark; a
|
|
6
|
-
// position:fixed handle would escape sr-only's clip and show a stray strip
|
|
7
|
-
// (below lg the panel is display:none but still fixed — handle appended, not rendered). zudolab/zudo-doc#1821
|
|
8
|
-
if (getComputedStyle(sidebar).position !== "fixed") return;
|
|
9
|
-
|
|
10
|
-
// Resizer allows a wider range (192–448px) than the CSS default
|
|
11
|
-
// (clamp(14rem, 20vw, 22rem) = 224–352px at 16px base).
|
|
12
|
-
// CSS provides the responsive initial width; the resizer lets users
|
|
13
|
-
// go beyond that range when explicitly dragging or using keyboard arrows.
|
|
14
|
-
const MIN_W = 192;
|
|
15
|
-
const MAX_W = 448;
|
|
16
|
-
const STEP = 10;
|
|
17
|
-
const LS_KEY = "zudo-doc-sidebar-width";
|
|
18
|
-
const CSS_PROP = "--zd-sidebar-w";
|
|
19
|
-
const ACCENT_BG = "var(--zd-accent, rgba(128,128,128,0.3))";
|
|
20
|
-
const ACCENT_OUTLINE = "2px solid var(--zd-accent, rgba(128,128,128,0.5))";
|
|
21
|
-
const ACCENT_GHOST = "var(--zd-accent, rgba(128,128,128,0.5))";
|
|
22
|
-
|
|
23
|
-
function readCurrentWidth(): number {
|
|
24
|
-
const raw = getComputedStyle(document.documentElement).getPropertyValue(CSS_PROP);
|
|
25
|
-
return raw ? parseFloat(raw) || MIN_W : MIN_W;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let cachedWidth = readCurrentWidth();
|
|
29
|
-
|
|
30
|
-
const handle = document.createElement("div");
|
|
31
|
-
handle.setAttribute("data-sidebar-resizer", "");
|
|
32
|
-
handle.setAttribute("tabindex", "0");
|
|
33
|
-
handle.setAttribute("role", "separator");
|
|
34
|
-
handle.setAttribute("aria-orientation", "vertical");
|
|
35
|
-
handle.setAttribute("aria-label", "Resize sidebar");
|
|
36
|
-
handle.setAttribute("aria-valuemin", String(MIN_W));
|
|
37
|
-
handle.setAttribute("aria-valuemax", String(MAX_W));
|
|
38
|
-
handle.setAttribute("aria-valuenow", String(Math.round(cachedWidth)));
|
|
39
|
-
// position:fixed (not absolute) pins the handle to the viewport so it spans
|
|
40
|
-
// the sidebar's full height even while #desktop-sidebar scrolls. As an
|
|
41
|
-
// absolute child of the overflow-y:auto sidebar the handle scrolled away with
|
|
42
|
-
// the content and its height:100% only resolved to the visible padding box,
|
|
43
|
-
// so the bottom of the sidebar lost its grab strip once scrolled. zudolab/zudo-doc#1821
|
|
44
|
-
//
|
|
45
|
-
// top:3.5rem + left:calc mirror the doc-layout #desktop-sidebar geometry
|
|
46
|
-
// (top-[3.5rem], left:0, width:var(--zd-sidebar-w)) — those layout constants
|
|
47
|
-
// live in the same package's doc-layout.tsx. 20px is wider than every common
|
|
48
|
-
// native y-scrollbar (~12-17px on Win/Linux classic; 0 on macOS overlay) so a
|
|
49
|
-
// draggable strip always remains visible to the LEFT of the scrollbar when
|
|
50
|
-
// sidebar content overflows. zudolab/zudo-doc#1660
|
|
51
|
-
Object.assign(handle.style, {
|
|
52
|
-
position: "fixed",
|
|
53
|
-
top: "3.5rem",
|
|
54
|
-
bottom: "0",
|
|
55
|
-
left: "calc(var(--zd-sidebar-w) - 20px)",
|
|
56
|
-
width: "20px",
|
|
57
|
-
cursor: "col-resize",
|
|
58
|
-
zIndex: "10",
|
|
59
|
-
transition: "background 0.15s",
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
let dragging = false;
|
|
63
|
-
|
|
64
|
-
function applyWidth(w: number) {
|
|
65
|
-
cachedWidth = Math.max(MIN_W, Math.min(MAX_W, w));
|
|
66
|
-
document.documentElement.style.setProperty(CSS_PROP, cachedWidth + "px");
|
|
67
|
-
try { localStorage.setItem(LS_KEY, String(Math.round(cachedWidth))); } catch {}
|
|
68
|
-
handle.setAttribute("aria-valuenow", String(Math.round(cachedWidth)));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
let focused = false;
|
|
72
|
-
|
|
73
|
-
function updateHandleVisual() {
|
|
74
|
-
if (dragging || focused) {
|
|
75
|
-
handle.style.background = ACCENT_BG;
|
|
76
|
-
} else {
|
|
77
|
-
handle.style.background = "";
|
|
78
|
-
}
|
|
79
|
-
handle.style.outline = focused && !dragging ? ACCENT_OUTLINE : "";
|
|
80
|
-
handle.style.outlineOffset = focused && !dragging ? "1px" : "";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
handle.addEventListener("focus", () => {
|
|
84
|
-
focused = true;
|
|
85
|
-
updateHandleVisual();
|
|
86
|
-
});
|
|
87
|
-
handle.addEventListener("blur", () => {
|
|
88
|
-
focused = false;
|
|
89
|
-
updateHandleVisual();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
handle.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
93
|
-
let w = cachedWidth;
|
|
94
|
-
switch (e.key) {
|
|
95
|
-
case "ArrowLeft":
|
|
96
|
-
w = Math.max(MIN_W, w - STEP);
|
|
97
|
-
break;
|
|
98
|
-
case "ArrowRight":
|
|
99
|
-
w = Math.min(MAX_W, w + STEP);
|
|
100
|
-
break;
|
|
101
|
-
case "Home":
|
|
102
|
-
w = MIN_W;
|
|
103
|
-
break;
|
|
104
|
-
case "End":
|
|
105
|
-
w = MAX_W;
|
|
106
|
-
break;
|
|
107
|
-
default:
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
e.preventDefault();
|
|
111
|
-
applyWidth(w);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
handle.addEventListener("mouseenter", () => {
|
|
115
|
-
if (!dragging && !focused) handle.style.background = ACCENT_BG;
|
|
116
|
-
});
|
|
117
|
-
handle.addEventListener("mouseleave", () => {
|
|
118
|
-
if (!dragging && !focused) handle.style.background = "";
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
handle.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
handle.setPointerCapture(e.pointerId);
|
|
124
|
-
dragging = true;
|
|
125
|
-
updateHandleVisual();
|
|
126
|
-
document.documentElement.style.cursor = "col-resize";
|
|
127
|
-
document.documentElement.style.userSelect = "none";
|
|
128
|
-
|
|
129
|
-
// Ghost line — cheap to move (no reflow), shows target position
|
|
130
|
-
const ghost = document.createElement("div");
|
|
131
|
-
Object.assign(ghost.style, {
|
|
132
|
-
position: "fixed",
|
|
133
|
-
top: "0",
|
|
134
|
-
width: "2px",
|
|
135
|
-
height: "100vh",
|
|
136
|
-
background: ACCENT_GHOST,
|
|
137
|
-
pointerEvents: "none",
|
|
138
|
-
zIndex: "9999",
|
|
139
|
-
});
|
|
140
|
-
const sidebarRect = sidebar.getBoundingClientRect();
|
|
141
|
-
const sidebarLeft = sidebarRect.left;
|
|
142
|
-
ghost.style.left = (sidebarLeft + sidebarRect.width) + "px";
|
|
143
|
-
document.body.appendChild(ghost);
|
|
144
|
-
let targetWidth = 0;
|
|
145
|
-
let cleaned = false;
|
|
146
|
-
|
|
147
|
-
const onMove = (ev: PointerEvent) => {
|
|
148
|
-
targetWidth = Math.max(MIN_W, Math.min(MAX_W, ev.clientX - sidebarLeft));
|
|
149
|
-
ghost.style.left = (sidebarLeft + targetWidth) + "px";
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const cleanup = () => {
|
|
153
|
-
if (cleaned) return;
|
|
154
|
-
cleaned = true;
|
|
155
|
-
dragging = false;
|
|
156
|
-
updateHandleVisual();
|
|
157
|
-
document.documentElement.style.cursor = "";
|
|
158
|
-
document.documentElement.style.userSelect = "";
|
|
159
|
-
ghost.remove();
|
|
160
|
-
handle.removeEventListener("pointermove", onMove);
|
|
161
|
-
handle.removeEventListener("pointerup", onUp);
|
|
162
|
-
handle.removeEventListener("pointercancel", onCancel);
|
|
163
|
-
handle.removeEventListener("lostpointercapture", onLost);
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const commit = () => {
|
|
167
|
-
if (targetWidth > 0) applyWidth(targetWidth);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
// pointerup: normal end-of-drag. Commit, then teardown.
|
|
171
|
-
const onUp = () => {
|
|
172
|
-
commit();
|
|
173
|
-
cleanup();
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
// lostpointercapture: per spec fires AFTER pointerup, but browsers reorder
|
|
177
|
-
// these in edge cases (cursor near y-scrollbar, fast drags, OS handoff).
|
|
178
|
-
// Commit here too so a real drag still applies if pointerup is dropped.
|
|
179
|
-
// Idempotent with onUp via the `cleaned` guard.
|
|
180
|
-
const onLost = () => {
|
|
181
|
-
commit();
|
|
182
|
-
cleanup();
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// pointercancel: actual user/OS cancellation (touch interrupted, etc.).
|
|
186
|
-
// Do NOT commit — caller intent was to abort.
|
|
187
|
-
const onCancel = () => {
|
|
188
|
-
cleanup();
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
handle.addEventListener("pointermove", onMove);
|
|
192
|
-
handle.addEventListener("pointerup", onUp);
|
|
193
|
-
handle.addEventListener("pointercancel", onCancel);
|
|
194
|
-
handle.addEventListener("lostpointercapture", onLost);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
sidebar.appendChild(handle);
|
|
198
|
-
}
|