@suzumiyaaoba/mdxr 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 +56 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2902 -0
- package/dist/components.d.mts +791 -0
- package/dist/components.mjs +2 -0
- package/dist/config-SI9IyFiC.mjs +184 -0
- package/dist/doc-context-CEqzMYKv.d.mts +568 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +4 -0
- package/dist/ui-GKFD0mx5.mjs +15986 -0
- package/package.json +131 -0
- package/skill/SKILL.md +49 -0
- package/skill/references/components/charts.md +172 -0
- package/skill/references/components/document.md +98 -0
- package/skill/references/components/forms.md +35 -0
- package/skill/references/components/investigation.md +198 -0
- package/skill/references/components/layout.md +68 -0
- package/skill/references/components/output.md +158 -0
- package/skill/references/components/planning.md +155 -0
- package/skill/references/components/reports.md +252 -0
- package/skill/references/components/shadcn.md +19 -0
- package/skill/references/components.md +186 -0
- package/skill/references/extending.md +48 -0
- package/src/ask-sheet.ts +40 -0
- package/src/assets/css.ts +392 -0
- package/src/assets/scripts.ts +78 -0
- package/src/catalog.ts +287 -0
- package/src/cli.ts +178 -0
- package/src/client/doc-events.ts +1108 -0
- package/src/client/entry.ts +27 -0
- package/src/client-js.ts +47 -0
- package/src/component-map.ts +47 -0
- package/src/components/ui/accordion.tsx +77 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +208 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/breadcrumb.tsx +121 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +88 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +226 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +246 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/combobox.tsx +298 -0
- package/src/components/ui/command.tsx +193 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +4 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +237 -0
- package/src/components/ui/fieldset.tsx +32 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +159 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +202 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +284 -0
- package/src/components/ui/message-scroller.tsx +128 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/meter.tsx +80 -0
- package/src/components/ui/native-select.tsx +64 -0
- package/src/components/ui/navigation-menu.tsx +170 -0
- package/src/components/ui/pagination.tsx +133 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +328 -0
- package/src/components/ui/radio-group.tsx +35 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/select.tsx +201 -0
- package/src/components/ui/separator.tsx +22 -0
- package/src/components/ui/sheet.tsx +135 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +229 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/components.ts +78 -0
- package/src/config.ts +56 -0
- package/src/define.ts +122 -0
- package/src/doc-context.ts +20 -0
- package/src/editor.ts +94 -0
- package/src/format-error.ts +78 -0
- package/src/guards.ts +84 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/html.ts +91 -0
- package/src/hydrate/export-index.ts +232 -0
- package/src/hydrate/import-scan.ts +169 -0
- package/src/hydrate/plugins.ts +118 -0
- package/src/hydrate/runtime-module.ts +145 -0
- package/src/hydrate-runtime.ts +67 -0
- package/src/hydrate.ts +147 -0
- package/src/index.ts +16 -0
- package/src/init.ts +57 -0
- package/src/langs.ts +148 -0
- package/src/lines.ts +53 -0
- package/src/load-user-module.ts +191 -0
- package/src/mdx.ts +248 -0
- package/src/paths.ts +36 -0
- package/src/rehype/shiki.ts +533 -0
- package/src/remark/alerts.ts +53 -0
- package/src/remark/ast.ts +96 -0
- package/src/remark/callouts.ts +25 -0
- package/src/remark/code-file.ts +85 -0
- package/src/remark/code-meta.ts +21 -0
- package/src/remark/directives.ts +170 -0
- package/src/remark/file-paths.ts +64 -0
- package/src/remark/headings.ts +131 -0
- package/src/remark/no-js.ts +40 -0
- package/src/render.ts +337 -0
- package/src/serve.ts +322 -0
- package/src/styles/globals.css +134 -0
- package/src/styles/shadcn.css +641 -0
- package/src/tailwind.ts +119 -0
- package/src/ui/approvals.tsx +76 -0
- package/src/ui/ask-question.tsx +386 -0
- package/src/ui/ask.tsx +206 -0
- package/src/ui/attrs.ts +51 -0
- package/src/ui/audit.tsx +139 -0
- package/src/ui/bar-chart.tsx +334 -0
- package/src/ui/benchmarks.tsx +143 -0
- package/src/ui/bits.tsx +537 -0
- package/src/ui/board.tsx +173 -0
- package/src/ui/bridge.tsx +207 -0
- package/src/ui/bumps.tsx +178 -0
- package/src/ui/callout.tsx +106 -0
- package/src/ui/changes.tsx +89 -0
- package/src/ui/chart-bits.tsx +52 -0
- package/src/ui/chart.ts +577 -0
- package/src/ui/checks.tsx +203 -0
- package/src/ui/child-index.tsx +44 -0
- package/src/ui/children.ts +43 -0
- package/src/ui/chips.tsx +49 -0
- package/src/ui/cmd.tsx +27 -0
- package/src/ui/columns.tsx +31 -0
- package/src/ui/comments.tsx +544 -0
- package/src/ui/compare.tsx +67 -0
- package/src/ui/decision.tsx +79 -0
- package/src/ui/deps.tsx +78 -0
- package/src/ui/details.tsx +43 -0
- package/src/ui/diff-parse.ts +307 -0
- package/src/ui/diff.tsx +458 -0
- package/src/ui/diffstat.tsx +58 -0
- package/src/ui/due.tsx +65 -0
- package/src/ui/effort.tsx +29 -0
- package/src/ui/endpoints.tsx +118 -0
- package/src/ui/envvars.tsx +96 -0
- package/src/ui/figure.tsx +37 -0
- package/src/ui/file-icon.ts +1020 -0
- package/src/ui/file-link.ts +32 -0
- package/src/ui/file-ref.tsx +39 -0
- package/src/ui/files.tsx +112 -0
- package/src/ui/findings.tsx +85 -0
- package/src/ui/flow.tsx +73 -0
- package/src/ui/funnel.tsx +121 -0
- package/src/ui/gantt.tsx +443 -0
- package/src/ui/gauges.tsx +135 -0
- package/src/ui/glossary.tsx +29 -0
- package/src/ui/graph-layout.ts +149 -0
- package/src/ui/graph-specs.tsx +102 -0
- package/src/ui/graph.tsx +278 -0
- package/src/ui/grid.tsx +119 -0
- package/src/ui/hypothesis.tsx +94 -0
- package/src/ui/icon.tsx +80 -0
- package/src/ui/incident.tsx +130 -0
- package/src/ui/index.ts +342 -0
- package/src/ui/ins-del.tsx +41 -0
- package/src/ui/json.tsx +190 -0
- package/src/ui/layout.ts +9 -0
- package/src/ui/line-chart.tsx +251 -0
- package/src/ui/matrix.tsx +208 -0
- package/src/ui/meta.tsx +73 -0
- package/src/ui/option.tsx +64 -0
- package/src/ui/owner.tsx +40 -0
- package/src/ui/packages.tsx +104 -0
- package/src/ui/pathway.tsx +96 -0
- package/src/ui/phase.tsx +41 -0
- package/src/ui/pie-chart.tsx +170 -0
- package/src/ui/plan.tsx +77 -0
- package/src/ui/pre.tsx +175 -0
- package/src/ui/priority.tsx +51 -0
- package/src/ui/props.tsx +77 -0
- package/src/ui/quadrant.tsx +172 -0
- package/src/ui/radar.tsx +200 -0
- package/src/ui/ref.tsx +160 -0
- package/src/ui/release.tsx +178 -0
- package/src/ui/req.tsx +42 -0
- package/src/ui/review.tsx +133 -0
- package/src/ui/risk.tsx +67 -0
- package/src/ui/sankey.tsx +287 -0
- package/src/ui/scatter.tsx +237 -0
- package/src/ui/schema.tsx +113 -0
- package/src/ui/score.tsx +105 -0
- package/src/ui/search.tsx +120 -0
- package/src/ui/series.tsx +38 -0
- package/src/ui/severity.tsx +69 -0
- package/src/ui/shadcn.tsx +216 -0
- package/src/ui/spark.tsx +86 -0
- package/src/ui/stack.tsx +57 -0
- package/src/ui/stats.tsx +63 -0
- package/src/ui/status-badge.tsx +66 -0
- package/src/ui/statuspage.tsx +238 -0
- package/src/ui/steps.tsx +74 -0
- package/src/ui/summary.tsx +41 -0
- package/src/ui/symbol-ref.tsx +73 -0
- package/src/ui/terminal.tsx +117 -0
- package/src/ui/tests.tsx +218 -0
- package/src/ui/timeline.tsx +63 -0
- package/src/ui/toc.tsx +56 -0
- package/src/ui/tones.ts +187 -0
- package/src/ui/trace.tsx +69 -0
- package/src/ui/tree.tsx +281 -0
- package/src/ui/treemap.tsx +128 -0
- package/src/ui/venn.tsx +258 -0
- package/src/ui/verdict.tsx +78 -0
- package/src/ui/waterfall.tsx +142 -0
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side document interactions — plain DOM code, no React. Bundled
|
|
3
|
+
* into every rendered document via `entry.ts` (esbuild → CLIENT_JS) and
|
|
4
|
+
* imported live by the Storybook preview, which binds `handleDocEvent`
|
|
5
|
+
* itself. Keep this module free of top-level side effects; entry.ts owns
|
|
6
|
+
* listener registration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatAnswerSheet } from "../ask-sheet.js";
|
|
10
|
+
import type { SheetEntry } from "../ask-sheet.js";
|
|
11
|
+
|
|
12
|
+
/** `el.closest(sel)` narrowed to HTMLElement — misses and non-HTML hits (SVG) are null. */
|
|
13
|
+
const closestEl = (el: Element, sel: string): HTMLElement | null => {
|
|
14
|
+
const hit = el.closest(sel);
|
|
15
|
+
return hit instanceof HTMLElement ? hit : null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Flashes a feedback state on a button: swaps to the .mdxr-copy-done icon /
|
|
19
|
+
// label and tints it via .copied (success) or shakes it red via
|
|
20
|
+
// .copy-failed. A repeat click restarts the pop (reflow) and the timer.
|
|
21
|
+
const flash = (
|
|
22
|
+
b: HTMLElement,
|
|
23
|
+
cls: "copied" | "copy-failed",
|
|
24
|
+
label?: string
|
|
25
|
+
): void => {
|
|
26
|
+
const ex = b as HTMLElement & {
|
|
27
|
+
mdxrLabel?: null | string;
|
|
28
|
+
mdxrTimer?: ReturnType<typeof setTimeout>;
|
|
29
|
+
};
|
|
30
|
+
clearTimeout(ex.mdxrTimer);
|
|
31
|
+
if (label !== undefined) {
|
|
32
|
+
ex.mdxrLabel ??= b.getAttribute("aria-label");
|
|
33
|
+
b.setAttribute("aria-label", label);
|
|
34
|
+
}
|
|
35
|
+
b.classList.remove("copied", "copy-failed");
|
|
36
|
+
void b.offsetWidth;
|
|
37
|
+
b.classList.add(cls);
|
|
38
|
+
ex.mdxrTimer = setTimeout(() => {
|
|
39
|
+
b.classList.remove(cls);
|
|
40
|
+
if (ex.mdxrLabel !== undefined) {
|
|
41
|
+
if (ex.mdxrLabel === null) {
|
|
42
|
+
b.removeAttribute("aria-label");
|
|
43
|
+
} else {
|
|
44
|
+
b.setAttribute("aria-label", ex.mdxrLabel);
|
|
45
|
+
}
|
|
46
|
+
ex.mdxrLabel = undefined;
|
|
47
|
+
}
|
|
48
|
+
}, 1600);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const writeClipboard = (
|
|
52
|
+
text: string,
|
|
53
|
+
done: () => void,
|
|
54
|
+
fail: () => void
|
|
55
|
+
): void => {
|
|
56
|
+
const legacy = (): boolean => {
|
|
57
|
+
const ta = document.createElement("textarea");
|
|
58
|
+
ta.value = text;
|
|
59
|
+
ta.setAttribute("readonly", "");
|
|
60
|
+
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
61
|
+
document.body.append(ta);
|
|
62
|
+
ta.select();
|
|
63
|
+
let ok = false;
|
|
64
|
+
try {
|
|
65
|
+
// oxlint-disable-next-line typescript/no-deprecated -- only fallback outside secure contexts
|
|
66
|
+
ok = document.execCommand("copy");
|
|
67
|
+
} catch {
|
|
68
|
+
ok = false;
|
|
69
|
+
}
|
|
70
|
+
ta.remove();
|
|
71
|
+
return ok;
|
|
72
|
+
};
|
|
73
|
+
// navigator.clipboard is absent outside secure contexts (e.g. file://).
|
|
74
|
+
const clip = navigator.clipboard as Clipboard | undefined;
|
|
75
|
+
if (clip === undefined) {
|
|
76
|
+
if (legacy()) {
|
|
77
|
+
done();
|
|
78
|
+
} else {
|
|
79
|
+
fail();
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
void (async () => {
|
|
84
|
+
try {
|
|
85
|
+
await clip.writeText(text);
|
|
86
|
+
done();
|
|
87
|
+
} catch {
|
|
88
|
+
if (legacy()) {
|
|
89
|
+
done();
|
|
90
|
+
} else {
|
|
91
|
+
fail();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Writes `text` to the clipboard and flashes `btn` accordingly; labels are
|
|
98
|
+
// optional so icon-only buttons can omit them.
|
|
99
|
+
const copyWithFeedback = (
|
|
100
|
+
btn: HTMLElement,
|
|
101
|
+
text: string,
|
|
102
|
+
doneLabel?: string,
|
|
103
|
+
failLabel?: string
|
|
104
|
+
): void => {
|
|
105
|
+
writeClipboard(
|
|
106
|
+
text,
|
|
107
|
+
() => {
|
|
108
|
+
flash(btn, "copied", doneLabel);
|
|
109
|
+
},
|
|
110
|
+
() => {
|
|
111
|
+
flash(btn, "copy-failed", failLabel);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// The text a reader actually selected for a choice/select answer: the
|
|
117
|
+
// choice card's visible label minus its description, or the <option> text.
|
|
118
|
+
// Falls back to the control's value when no text can be read.
|
|
119
|
+
const choiceText = (f: Element): string => {
|
|
120
|
+
if (f instanceof HTMLOptionElement) {
|
|
121
|
+
// Same whitespace collapse as SSR's choiceLabel — otherwise the live
|
|
122
|
+
// sheet rewrites an SSR-seeded "a b" label as "a b" on first sync.
|
|
123
|
+
const t = (f.textContent ?? "").replaceAll(/\s+/gu, " ").trim();
|
|
124
|
+
return t === "" ? f.value : t;
|
|
125
|
+
}
|
|
126
|
+
const fallback = f instanceof HTMLInputElement ? f.value : "";
|
|
127
|
+
const body = f.closest("label")?.querySelector(".mdxr-choice-text");
|
|
128
|
+
if (!(body instanceof HTMLElement)) {
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
let t = "";
|
|
132
|
+
for (const n of body.childNodes) {
|
|
133
|
+
if (n instanceof HTMLElement && n.classList.contains("mdxr-choice-desc")) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
t += n.textContent ?? "";
|
|
137
|
+
}
|
|
138
|
+
t = t.replaceAll(/\s+/gu, " ").trim();
|
|
139
|
+
return t === "" ? fallback : t;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const multiAnswer = (q: HTMLElement): string => {
|
|
143
|
+
const vals: string[] = [];
|
|
144
|
+
for (const f of q.querySelectorAll("input:checked")) {
|
|
145
|
+
const v = choiceText(f);
|
|
146
|
+
if (v !== "") {
|
|
147
|
+
vals.push(v);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return vals.join(", ");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const choiceAnswer = (q: HTMLElement): string => {
|
|
154
|
+
const f = q.querySelector("input:checked");
|
|
155
|
+
return f === null ? "" : choiceText(f);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const selectAnswer = (q: HTMLElement): string => {
|
|
159
|
+
const s = q.querySelector("select");
|
|
160
|
+
const [opt] =
|
|
161
|
+
s instanceof HTMLSelectElement && s.value !== "" ? s.selectedOptions : [];
|
|
162
|
+
return opt === undefined ? "" : choiceText(opt);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const fieldAnswer = (q: HTMLElement): string => {
|
|
166
|
+
const f = q.querySelector("input, textarea");
|
|
167
|
+
if (f instanceof HTMLTextAreaElement) {
|
|
168
|
+
return f.value.trim();
|
|
169
|
+
}
|
|
170
|
+
if (!(f instanceof HTMLInputElement)) {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
if (f.type === "checkbox") {
|
|
174
|
+
// Toggle questions are checkboxes — always answered, yes or no.
|
|
175
|
+
return f.checked ? "yes" : "no";
|
|
176
|
+
}
|
|
177
|
+
return f.value.trim();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const ANSWER_READERS: Record<string, (q: HTMLElement) => string> = {
|
|
181
|
+
choice: choiceAnswer,
|
|
182
|
+
multi: multiAnswer,
|
|
183
|
+
select: selectAnswer,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// One question's answer: checked choices read as their visible text,
|
|
187
|
+
// selects as the chosen option's text, toggles as yes/no, free-form
|
|
188
|
+
// fields as their raw value. "" means unanswered.
|
|
189
|
+
const answerOf = (q: HTMLElement): string =>
|
|
190
|
+
ANSWER_READERS[q.dataset.qType ?? ""]?.(q) ?? fieldAnswer(q);
|
|
191
|
+
|
|
192
|
+
// Serializes an [data-ask] block into the Markdown answer sheet shown in
|
|
193
|
+
// [data-ask-output]: `# title` then one `- **label**: answer` line per
|
|
194
|
+
// question, in DOM order. Unanswered fields stay blank; toggles are
|
|
195
|
+
// always answered (yes/no). Multi-line answers indent under their item.
|
|
196
|
+
// The line format is shared with SSR via ask-sheet.ts.
|
|
197
|
+
const askMarkdown = (box: HTMLElement): string => {
|
|
198
|
+
const entries: SheetEntry[] = [];
|
|
199
|
+
for (const q of box.querySelectorAll("[data-mdxr-q]")) {
|
|
200
|
+
if (!(q instanceof HTMLElement)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
entries.push({ answer: answerOf(q), label: q.dataset.qLabel ?? "" });
|
|
204
|
+
}
|
|
205
|
+
return formatAnswerSheet(box.dataset.askTitle, entries);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Re-renders the answer sheet into the block's [data-ask-output] pane.
|
|
209
|
+
const syncAsk = (box: HTMLElement): void => {
|
|
210
|
+
const out = box.querySelector("[data-ask-output]");
|
|
211
|
+
if (out !== null) {
|
|
212
|
+
out.textContent = askMarkdown(box);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/* ---- Board: drag & drop, move buttons, markdown copy ----------------------
|
|
217
|
+
* Cards are `draggable` and carry their props in `data-card-*` attributes,
|
|
218
|
+
* so the current arrangement serializes straight back into `<Board>` markup
|
|
219
|
+
* (`data-board-copy` copies it — paste it over the source block to persist
|
|
220
|
+
* a reorganization). `[data-board-move]` buttons cover keyboards and touch
|
|
221
|
+
* (HTML5 DnD is absent there). All of it is plain DOM work — no hydration.
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
// The card mid-drag, or null. Non-null also gates dragover/drop handling so
|
|
225
|
+
// unrelated drags (files, text selections) keep their native behavior.
|
|
226
|
+
let draggedCard: HTMLElement | null = null;
|
|
227
|
+
|
|
228
|
+
const DROP_HINTS = ["mdxr-drop-before", "mdxr-drop-end", "mdxr-drop-lane"];
|
|
229
|
+
|
|
230
|
+
// A board's own lanes — a Board nested in a card's children belongs to the
|
|
231
|
+
// inner board, so `closest` on each candidate does the assignment.
|
|
232
|
+
const lanesOf = (board: HTMLElement): HTMLElement[] =>
|
|
233
|
+
[...board.querySelectorAll("[data-board-lane]")].filter(
|
|
234
|
+
(l): l is HTMLElement =>
|
|
235
|
+
l instanceof HTMLElement && l.closest("[data-board]") === board
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// The lane's cards: direct children of its `[data-board-cards]` box.
|
|
239
|
+
const cardsOf = (lane: HTMLElement): HTMLElement[] => {
|
|
240
|
+
const box = lane.querySelector(":scope > [data-board-cards]");
|
|
241
|
+
if (box === null) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
return [...box.children].filter(
|
|
245
|
+
(c): c is HTMLElement =>
|
|
246
|
+
c instanceof HTMLElement && Object.hasOwn(c.dataset, "boardCard")
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const clearDropHints = (board: HTMLElement): void => {
|
|
251
|
+
for (const n of board.querySelectorAll(`.${DROP_HINTS.join(", .")}`)) {
|
|
252
|
+
n.classList.remove(...DROP_HINTS);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// After any move the lane counts and the move buttons' `disabled` state
|
|
257
|
+
// follow the DOM (first-lane cards can't go left, last-lane can't go right).
|
|
258
|
+
const syncBoard = (board: HTMLElement): void => {
|
|
259
|
+
const lanes = lanesOf(board);
|
|
260
|
+
for (const [i, lane] of lanes.entries()) {
|
|
261
|
+
const cards = cardsOf(lane);
|
|
262
|
+
const badge = lane.querySelector(":scope > header .mdxr-lane-count");
|
|
263
|
+
if (badge !== null) {
|
|
264
|
+
badge.textContent = String(cards.length);
|
|
265
|
+
}
|
|
266
|
+
for (const card of cards) {
|
|
267
|
+
const prev = card.querySelector('[data-board-move="-1"]');
|
|
268
|
+
const next = card.querySelector('[data-board-move="1"]');
|
|
269
|
+
if (prev instanceof HTMLButtonElement) {
|
|
270
|
+
prev.disabled = i === 0;
|
|
271
|
+
}
|
|
272
|
+
if (next instanceof HTMLButtonElement) {
|
|
273
|
+
next.disabled = i === lanes.length - 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Initial counts + move-button state for every board under `root`. */
|
|
280
|
+
export const syncBoards = (root: ParentNode): void => {
|
|
281
|
+
for (const b of root.querySelectorAll("[data-board]")) {
|
|
282
|
+
if (b instanceof HTMLElement) {
|
|
283
|
+
syncBoard(b);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const moveCard = (card: HTMLElement, dir: number): void => {
|
|
289
|
+
const board = closestEl(card, "[data-board]");
|
|
290
|
+
const lane = closestEl(card, "[data-board-lane]");
|
|
291
|
+
if (board === null || lane === null) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const lanes = lanesOf(board);
|
|
295
|
+
const target = lanes[lanes.indexOf(lane) + dir];
|
|
296
|
+
target?.querySelector(":scope > [data-board-cards]")?.append(card);
|
|
297
|
+
if (target !== undefined) {
|
|
298
|
+
syncBoard(board);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// The insertion point for a pointer at clientY: the first card whose
|
|
303
|
+
// vertical midpoint sits below it (the dragged card aside), i.e. "drop
|
|
304
|
+
// before this card"; null means append at the lane's end.
|
|
305
|
+
const dropBefore = (lane: HTMLElement, clientY: number): HTMLElement | null => {
|
|
306
|
+
for (const c of cardsOf(lane)) {
|
|
307
|
+
if (c === draggedCard) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const r = c.getBoundingClientRect();
|
|
311
|
+
if (clientY < r.top + r.height / 2) {
|
|
312
|
+
return c;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Attribute in the emitted markup, always `name="v"`: JSX decodes entities
|
|
319
|
+
// in attribute strings, so `&`/`"`/`<`/control chars escape as entities —
|
|
320
|
+
// the emitted markup stays expression-free (mdxr forbids JS expressions).
|
|
321
|
+
const jsxAttr = (name: string, value: string): string =>
|
|
322
|
+
` ${name}="${value
|
|
323
|
+
.replaceAll("&", "&")
|
|
324
|
+
.replaceAll('"', """)
|
|
325
|
+
.replaceAll("<", "<")
|
|
326
|
+
.replaceAll("\n", " ")
|
|
327
|
+
.replaceAll("\r", " ")
|
|
328
|
+
.replaceAll("\t", "	")}"`;
|
|
329
|
+
|
|
330
|
+
// Card children serialize as markdown text inside <BoardCard>…</BoardCard>:
|
|
331
|
+
// entities cover `&`/`<`/braces (JSX markers), backslash escapes cover
|
|
332
|
+
// markdown's inline specials and line-leading block markers, so the text
|
|
333
|
+
// re-parses as it reads. Formatting the children had is already lost —
|
|
334
|
+
// data-card-text is the flattened visible text.
|
|
335
|
+
const mdText = (s: string): string =>
|
|
336
|
+
s
|
|
337
|
+
.replaceAll("\\", "\\\\")
|
|
338
|
+
.replaceAll("&", "&")
|
|
339
|
+
.replaceAll("<", "<")
|
|
340
|
+
.replaceAll("{", "{")
|
|
341
|
+
.replaceAll("}", "}")
|
|
342
|
+
.replaceAll(/(?<ch>[`*_[\]])/gu, "\\$<ch>")
|
|
343
|
+
.replaceAll(
|
|
344
|
+
/(?<bol>^|\n)(?<ws>[ \t]*)(?<mark>[-+>#])/gu,
|
|
345
|
+
"$<bol>$<ws>\\$<mark>"
|
|
346
|
+
)
|
|
347
|
+
.replaceAll(
|
|
348
|
+
/(?<bol>^|\n)(?<ws>[ \t]*)(?<num>\d+)(?<dot>[.)])(?=\s|$)/gu,
|
|
349
|
+
"$<bol>$<ws>$<num>\\$<dot>"
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// `[attr name, dataset key]` in emitted order: title first (required),
|
|
353
|
+
// then the chips in their rendered order.
|
|
354
|
+
const CARD_ATTRS = [
|
|
355
|
+
["status", "cardStatus"],
|
|
356
|
+
["priority", "cardPriority"],
|
|
357
|
+
["effort", "cardEffort"],
|
|
358
|
+
["owner", "cardOwner"],
|
|
359
|
+
["due", "cardDue"],
|
|
360
|
+
] as const;
|
|
361
|
+
|
|
362
|
+
// Serializes a board's current DOM into `<Board>`/`<Lane>`/`<BoardCard>`
|
|
363
|
+
// markup — the source form, so pasting it back persists the arrangement.
|
|
364
|
+
const boardMarkdown = (board: HTMLElement): string => {
|
|
365
|
+
const attr = (name: string, value: string | undefined): string =>
|
|
366
|
+
value === undefined || value === "" ? "" : jsxAttr(name, value);
|
|
367
|
+
const lines = [`<Board${attr("title", board.dataset.boardTitle)}>`];
|
|
368
|
+
for (const lane of lanesOf(board)) {
|
|
369
|
+
lines.push(
|
|
370
|
+
` <Lane${attr("title", lane.dataset.laneTitle)}${attr("status", lane.dataset.laneStatus)}>`
|
|
371
|
+
);
|
|
372
|
+
for (const card of cardsOf(lane)) {
|
|
373
|
+
const d = card.dataset;
|
|
374
|
+
const attrs = CARD_ATTRS.map(([name, key]) => attr(name, d[key])).join(
|
|
375
|
+
""
|
|
376
|
+
);
|
|
377
|
+
const open = ` <BoardCard${attr("title", d.cardTitle)}${attrs}`;
|
|
378
|
+
lines.push(
|
|
379
|
+
d.cardText === undefined || d.cardText === ""
|
|
380
|
+
? `${open} />`
|
|
381
|
+
: `${open}>${mdText(d.cardText)}</BoardCard>`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
lines.push(" </Lane>");
|
|
385
|
+
}
|
|
386
|
+
lines.push("</Board>");
|
|
387
|
+
return lines.join("\n");
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Downloads the answer sheet as `<slug>.md` via a temporary blob link.
|
|
391
|
+
const saveAsk = (box: HTMLElement, btn: HTMLElement): void => {
|
|
392
|
+
syncAsk(box);
|
|
393
|
+
const slug = (box.dataset.askTitle ?? "answers")
|
|
394
|
+
.toLowerCase()
|
|
395
|
+
.replaceAll(/[^\p{L}\p{N}]+/gu, "-")
|
|
396
|
+
.replaceAll(/^-+|-+$/gu, "");
|
|
397
|
+
const url = URL.createObjectURL(
|
|
398
|
+
new Blob([askMarkdown(box)], { type: "text/markdown;charset=utf-8" })
|
|
399
|
+
);
|
|
400
|
+
const a = document.createElement("a");
|
|
401
|
+
a.href = url;
|
|
402
|
+
a.download = `${slug === "" ? "answers" : slug}.md`;
|
|
403
|
+
document.body.append(a);
|
|
404
|
+
a.click();
|
|
405
|
+
a.remove();
|
|
406
|
+
setTimeout(() => {
|
|
407
|
+
URL.revokeObjectURL(url);
|
|
408
|
+
}, 1000);
|
|
409
|
+
flash(btn, "copied");
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// localStorage can throw on file:// or in hardened contexts — reads fall
|
|
413
|
+
// back to "auto", writes are best-effort (the toggle still applies for
|
|
414
|
+
// this view).
|
|
415
|
+
const storedTheme = (): string => {
|
|
416
|
+
try {
|
|
417
|
+
return localStorage.getItem("mdxr-theme") ?? "auto";
|
|
418
|
+
} catch {
|
|
419
|
+
return "auto";
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const persistTheme = (mode: string): void => {
|
|
424
|
+
try {
|
|
425
|
+
if (mode === "auto") {
|
|
426
|
+
localStorage.removeItem("mdxr-theme");
|
|
427
|
+
} else {
|
|
428
|
+
localStorage.setItem("mdxr-theme", mode);
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
// Best-effort — see above.
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const NEXT_MODE: Record<string, string> = {
|
|
436
|
+
auto: "light",
|
|
437
|
+
dark: "auto",
|
|
438
|
+
light: "dark",
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// Cycles auto → light → dark, persists the choice, and repaints every
|
|
442
|
+
// toggle's icon/label to match.
|
|
443
|
+
const cycleTheme = (): void => {
|
|
444
|
+
const stored = storedTheme();
|
|
445
|
+
const mode = stored === "light" || stored === "dark" ? stored : "auto";
|
|
446
|
+
const next = NEXT_MODE[mode] ?? "auto";
|
|
447
|
+
persistTheme(next);
|
|
448
|
+
const dark =
|
|
449
|
+
next === "dark" ||
|
|
450
|
+
(next === "auto" && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
451
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
452
|
+
const title = `Theme: ${next}`;
|
|
453
|
+
for (const b of document.querySelectorAll("[data-mdxr-theme]")) {
|
|
454
|
+
if (!(b instanceof HTMLElement)) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
b.dataset.mode = next;
|
|
458
|
+
b.setAttribute("title", title);
|
|
459
|
+
b.setAttribute("aria-label", `Switch theme (current: ${next})`);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// The lane + home board when `el` sits over a valid drop target for the
|
|
464
|
+
// active drag — lanes of the dragged card's own board only, not other
|
|
465
|
+
// boards and not the gaps between lanes.
|
|
466
|
+
const dragLane = (
|
|
467
|
+
el: Element
|
|
468
|
+
): { home: HTMLElement; lane: HTMLElement } | null => {
|
|
469
|
+
if (draggedCard === null) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const home = closestEl(draggedCard, "[data-board]");
|
|
473
|
+
const lane = closestEl(el, "[data-board-lane]");
|
|
474
|
+
if (home === null || lane === null || lane.closest("[data-board]") !== home) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
return { home, lane };
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const dragStart = (e: Event, el: Element): boolean => {
|
|
481
|
+
const card = closestEl(el, "[data-board-card]");
|
|
482
|
+
if (card === null || card.closest("[data-board]") === null) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
draggedCard = card;
|
|
486
|
+
if (e instanceof DragEvent && e.dataTransfer !== null) {
|
|
487
|
+
e.dataTransfer.effectAllowed = "move";
|
|
488
|
+
// Firefox won't start a drag without setData.
|
|
489
|
+
e.dataTransfer.setData("text/plain", card.dataset.cardTitle ?? "");
|
|
490
|
+
}
|
|
491
|
+
// Defer so the drag image keeps the card's normal look. Re-check the
|
|
492
|
+
// active drag: a same-frame drop/dragend would otherwise leave a stale
|
|
493
|
+
// mdxr-drag class behind after dragFinish already ran.
|
|
494
|
+
requestAnimationFrame(() => {
|
|
495
|
+
if (draggedCard === card) {
|
|
496
|
+
card.classList.add("mdxr-drag");
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return true;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Ends the active drag — drop/dragend both land here; `home` clears the
|
|
503
|
+
// insertion-point hints when known.
|
|
504
|
+
const dragFinish = (home: HTMLElement | null): void => {
|
|
505
|
+
if (draggedCard === null) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
draggedCard.classList.remove("mdxr-drag");
|
|
509
|
+
if (home !== null) {
|
|
510
|
+
clearDropHints(home);
|
|
511
|
+
}
|
|
512
|
+
draggedCard = null;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const dragOver = (e: Event, el: Element): boolean => {
|
|
516
|
+
if (draggedCard === null) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
const target = dragLane(el);
|
|
520
|
+
const home = closestEl(draggedCard, "[data-board]");
|
|
521
|
+
if (target === null) {
|
|
522
|
+
// Off the lanes: clear stale hints, keep the no-drop cursor.
|
|
523
|
+
if (home !== null) {
|
|
524
|
+
clearDropHints(home);
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
const box = target.lane.querySelector(":scope > [data-board-cards]");
|
|
530
|
+
if (box === null) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
if (e instanceof DragEvent && e.dataTransfer !== null) {
|
|
534
|
+
e.dataTransfer.dropEffect = "move";
|
|
535
|
+
}
|
|
536
|
+
const before = dropBefore(
|
|
537
|
+
target.lane,
|
|
538
|
+
e instanceof DragEvent ? e.clientY : 0
|
|
539
|
+
);
|
|
540
|
+
clearDropHints(target.home);
|
|
541
|
+
target.lane.classList.add("mdxr-drop-lane");
|
|
542
|
+
if (before === null) {
|
|
543
|
+
box.classList.add("mdxr-drop-end");
|
|
544
|
+
} else {
|
|
545
|
+
before.classList.add("mdxr-drop-before");
|
|
546
|
+
}
|
|
547
|
+
return true;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const drop = (e: Event, el: Element): boolean => {
|
|
551
|
+
if (draggedCard === null) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
const target = dragLane(el);
|
|
555
|
+
if (target === null) {
|
|
556
|
+
// Keep the card's text out of fields/links outside the board.
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
dragFinish(closestEl(draggedCard, "[data-board]"));
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
const box = target.lane.querySelector(":scope > [data-board-cards]");
|
|
563
|
+
const before = dropBefore(
|
|
564
|
+
target.lane,
|
|
565
|
+
e instanceof DragEvent ? e.clientY : 0
|
|
566
|
+
);
|
|
567
|
+
if (box !== null) {
|
|
568
|
+
if (before === null) {
|
|
569
|
+
box.append(draggedCard);
|
|
570
|
+
} else {
|
|
571
|
+
before.before(draggedCard);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
dragFinish(target.home);
|
|
575
|
+
syncBoard(target.home);
|
|
576
|
+
return true;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/* ---- Comments: line anchors, reply form, markdown copy -------------------
|
|
580
|
+
* <Comments> markup carries the hooks: [data-mdxr-comment] cards inside
|
|
581
|
+
* [data-comment-strip] threads, a [data-comment-add] "+" on every code/diff
|
|
582
|
+
* row, [data-thread-tools] holding each thread's Reply affordance, and
|
|
583
|
+
* [data-comments-copy] on the block's tools row. The strip/card/form markup
|
|
584
|
+
* ships as inert <template data-comment-tpl> fragments inside the
|
|
585
|
+
* [data-comments] root — handlers clone them so client-built markup stays
|
|
586
|
+
* single-sourced in SSR. [data-comments-copy] serializes the block's DOM
|
|
587
|
+
* back into <Comments> markup — paste it over the source block to persist
|
|
588
|
+
* a review.
|
|
589
|
+
*/
|
|
590
|
+
|
|
591
|
+
interface CommentAnchor {
|
|
592
|
+
file?: string;
|
|
593
|
+
lines?: string;
|
|
594
|
+
side?: string;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Mirrors owner.tsx's initials — the client bundle can't reach the React
|
|
598
|
+
// helper without dragging the component tree into the IIFE.
|
|
599
|
+
const commentInitials = (name: string): string => {
|
|
600
|
+
const clean = name.replace(/^@+/u, "").trim();
|
|
601
|
+
const parts = clean.split(/[\s._-]+/u).filter(Boolean);
|
|
602
|
+
const chars =
|
|
603
|
+
parts.length > 0
|
|
604
|
+
? parts.slice(0, 2).map((w) => w.charAt(0))
|
|
605
|
+
: [clean.charAt(0)];
|
|
606
|
+
return chars.join("").toUpperCase();
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// dataset writes skip empty/undefined values — `d.k = undefined` would set
|
|
610
|
+
// the literal "undefined" attribute.
|
|
611
|
+
const dataName = (key: string): string =>
|
|
612
|
+
`data-${key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`)}`;
|
|
613
|
+
|
|
614
|
+
const setData = (
|
|
615
|
+
el: HTMLElement,
|
|
616
|
+
key: string,
|
|
617
|
+
value: string | undefined
|
|
618
|
+
): void => {
|
|
619
|
+
if (value === undefined || value === "") {
|
|
620
|
+
el.removeAttribute(dataName(key));
|
|
621
|
+
} else {
|
|
622
|
+
el.setAttribute(dataName(key), value);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// Clone one <template data-comment-tpl> fragment parked inside `root`
|
|
627
|
+
// (the [data-comments] block). Returns the fragment's single root element.
|
|
628
|
+
// Parsed templates keep their kids in .content; hydrated ones (React
|
|
629
|
+
// rebuilds them as real element children) keep them as direct children —
|
|
630
|
+
// read both.
|
|
631
|
+
const cloneTpl = (root: HTMLElement, kind: string): HTMLElement | null => {
|
|
632
|
+
const tpl = root.querySelector(`template[data-comment-tpl="${kind}"]`);
|
|
633
|
+
if (!(tpl instanceof HTMLTemplateElement)) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
const node = (
|
|
637
|
+
tpl.content.firstElementChild ?? tpl.firstElementChild
|
|
638
|
+
)?.cloneNode(true);
|
|
639
|
+
return node instanceof HTMLElement ? node : null;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// The strip's own anchor (replies inherit it): data-strip-* the SSR set
|
|
643
|
+
// from the thread's last comment.
|
|
644
|
+
const anchorOfStrip = (strip: HTMLElement): CommentAnchor => ({
|
|
645
|
+
file: strip.dataset.stripFile,
|
|
646
|
+
lines: strip.dataset.stripLines,
|
|
647
|
+
side: strip.dataset.stripSide,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Append a cloned comment form to `strip`, hiding its Reply tools until the
|
|
651
|
+
// form closes. A strip already hosting a form just refocuses it.
|
|
652
|
+
const openCommentForm = (
|
|
653
|
+
root: HTMLElement,
|
|
654
|
+
strip: HTMLElement,
|
|
655
|
+
anchor: CommentAnchor
|
|
656
|
+
): void => {
|
|
657
|
+
const live = strip.querySelector(":scope > [data-comment-form]");
|
|
658
|
+
if (live instanceof HTMLElement) {
|
|
659
|
+
live.querySelector("textarea")?.focus();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const form = cloneTpl(root, "form");
|
|
663
|
+
if (form === null) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
setData(form, "formFile", anchor.file);
|
|
667
|
+
setData(form, "formLines", anchor.lines);
|
|
668
|
+
setData(form, "formSide", anchor.side);
|
|
669
|
+
const tools = strip.querySelector(":scope > [data-thread-tools]");
|
|
670
|
+
if (tools instanceof HTMLElement) {
|
|
671
|
+
tools.hidden = true;
|
|
672
|
+
}
|
|
673
|
+
strip.append(form);
|
|
674
|
+
form.querySelector("textarea")?.focus();
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Remove the form and bring back the strip's Reply tools. A strip with no
|
|
678
|
+
// cards left is one "+" opened and the user abandoned — drop it entirely
|
|
679
|
+
// and hand focus back to the row's "+".
|
|
680
|
+
const closeCommentForm = (form: HTMLElement): void => {
|
|
681
|
+
const strip = form.parentElement;
|
|
682
|
+
form.remove();
|
|
683
|
+
if (
|
|
684
|
+
!(strip instanceof HTMLElement) ||
|
|
685
|
+
!Object.hasOwn(strip.dataset, "commentStrip")
|
|
686
|
+
) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (strip.querySelector("[data-mdxr-comment]") === null) {
|
|
690
|
+
const add =
|
|
691
|
+
strip.previousElementSibling?.querySelector("[data-comment-add]");
|
|
692
|
+
strip.remove();
|
|
693
|
+
if (add instanceof HTMLElement) {
|
|
694
|
+
add.focus();
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const tools = strip.querySelector(":scope > [data-thread-tools]");
|
|
699
|
+
if (!(tools instanceof HTMLElement)) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
tools.hidden = false;
|
|
703
|
+
const reply = tools.querySelector("[data-comment-reply]");
|
|
704
|
+
if (reply instanceof HTMLElement) {
|
|
705
|
+
reply.focus();
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// Fill one [data-cc-*] slot with text — unused slots leave the clone so the
|
|
710
|
+
// built card matches SSR markup (no empty spans/avatar).
|
|
711
|
+
const fillSlot = (
|
|
712
|
+
card: HTMLElement,
|
|
713
|
+
slot: string,
|
|
714
|
+
value: string | undefined
|
|
715
|
+
): void => {
|
|
716
|
+
const el = card.querySelector(slot);
|
|
717
|
+
if (value === undefined || value === "") {
|
|
718
|
+
el?.remove();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (el !== null) {
|
|
722
|
+
el.textContent = value;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Build a [data-mdxr-comment] card off the `card` template: header slots
|
|
727
|
+
// filled from the anchor + author, body split into <p> paragraphs, and the
|
|
728
|
+
// data-comment-* attributes the markdown serializer reads.
|
|
729
|
+
const buildCommentCard = (
|
|
730
|
+
root: HTMLElement,
|
|
731
|
+
anchor: CommentAnchor,
|
|
732
|
+
author: string,
|
|
733
|
+
text: string
|
|
734
|
+
): HTMLElement | null => {
|
|
735
|
+
const card = cloneTpl(root, "card");
|
|
736
|
+
if (card === null) {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
setData(card, "commentAuthor", author);
|
|
740
|
+
setData(card, "commentFile", anchor.file);
|
|
741
|
+
setData(card, "commentLines", anchor.lines);
|
|
742
|
+
setData(card, "commentSide", anchor.side === "old" ? "old" : undefined);
|
|
743
|
+
setData(card, "commentText", text);
|
|
744
|
+
fillSlot(card, "[data-cc-name]", author.replace(/^@+/u, ""));
|
|
745
|
+
fillSlot(
|
|
746
|
+
card,
|
|
747
|
+
"[data-cc-lines]",
|
|
748
|
+
anchor.lines === undefined ? undefined : `:${anchor.lines}`
|
|
749
|
+
);
|
|
750
|
+
if (anchor.side !== "old") {
|
|
751
|
+
card.querySelector("[data-cc-old]")?.remove();
|
|
752
|
+
}
|
|
753
|
+
if (author === "") {
|
|
754
|
+
card.querySelector("[data-cc-avatar]")?.remove();
|
|
755
|
+
} else {
|
|
756
|
+
card.querySelector("[data-cc-avatar-anon]")?.remove();
|
|
757
|
+
fillSlot(card, "[data-cc-avatar]", commentInitials(author));
|
|
758
|
+
}
|
|
759
|
+
const body = card.querySelector("[data-cc-body]");
|
|
760
|
+
for (const para of text.split(/\n{2,}/u)) {
|
|
761
|
+
const p = document.createElement("p");
|
|
762
|
+
p.textContent = para;
|
|
763
|
+
body?.append(p);
|
|
764
|
+
}
|
|
765
|
+
return card;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const submitCommentForm = (form: HTMLElement): void => {
|
|
769
|
+
const strip = closestEl(form, "[data-comment-strip]");
|
|
770
|
+
const root = closestEl(form, "[data-comments]");
|
|
771
|
+
const input = form.querySelector("[data-comment-input]");
|
|
772
|
+
const nameField = form.querySelector("[data-comment-author]");
|
|
773
|
+
if (
|
|
774
|
+
strip === null ||
|
|
775
|
+
root === null ||
|
|
776
|
+
!(input instanceof HTMLTextAreaElement)
|
|
777
|
+
) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const text = input.value.trim();
|
|
781
|
+
if (text === "") {
|
|
782
|
+
input.focus();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const card = buildCommentCard(
|
|
786
|
+
root,
|
|
787
|
+
{
|
|
788
|
+
file: form.dataset.formFile,
|
|
789
|
+
lines: form.dataset.formLines,
|
|
790
|
+
side: form.dataset.formSide,
|
|
791
|
+
},
|
|
792
|
+
nameField instanceof HTMLInputElement ? nameField.value.trim() : "",
|
|
793
|
+
text
|
|
794
|
+
);
|
|
795
|
+
if (card === null) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
// New cards land ahead of the tools row — last in the thread.
|
|
799
|
+
const tools = strip.querySelector(":scope > [data-thread-tools]");
|
|
800
|
+
if (tools === null) {
|
|
801
|
+
strip.append(card);
|
|
802
|
+
} else {
|
|
803
|
+
tools.before(card);
|
|
804
|
+
}
|
|
805
|
+
closeCommentForm(form);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// The thread strip under a line row: the one already there (its anchor
|
|
809
|
+
// stands), else a fresh clone inserted after the row carrying the "+"
|
|
810
|
+
// button's anchor.
|
|
811
|
+
const stripAfterRow = (
|
|
812
|
+
row: HTMLElement,
|
|
813
|
+
root: HTMLElement,
|
|
814
|
+
anchor: CommentAnchor
|
|
815
|
+
): HTMLElement | null => {
|
|
816
|
+
const next = row.nextElementSibling;
|
|
817
|
+
if (
|
|
818
|
+
next instanceof HTMLElement &&
|
|
819
|
+
Object.hasOwn(next.dataset, "commentStrip")
|
|
820
|
+
) {
|
|
821
|
+
return next;
|
|
822
|
+
}
|
|
823
|
+
const strip = cloneTpl(root, "strip");
|
|
824
|
+
if (strip === null) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
setData(strip, "stripLines", anchor.lines);
|
|
828
|
+
setData(strip, "stripSide", anchor.side);
|
|
829
|
+
setData(strip, "stripFile", anchor.file);
|
|
830
|
+
row.after(strip);
|
|
831
|
+
return strip;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// "+" on a code/diff row: open the form in the row's strip, anchored to the
|
|
835
|
+
// button's line (and side/file for diffs). An existing strip under the row
|
|
836
|
+
// absorbs the new comment — GitHub keeps one thread per line.
|
|
837
|
+
const openLineComment = (btn: HTMLElement): void => {
|
|
838
|
+
const row = closestEl(btn, "[data-comment-row]");
|
|
839
|
+
const root = closestEl(btn, "[data-comments]");
|
|
840
|
+
if (row === null || root === null) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const anchor: CommentAnchor = {
|
|
844
|
+
file: btn.dataset.file,
|
|
845
|
+
lines: btn.dataset.commentAdd,
|
|
846
|
+
side: btn.dataset.side,
|
|
847
|
+
};
|
|
848
|
+
const strip = stripAfterRow(row, root, anchor);
|
|
849
|
+
if (strip !== null) {
|
|
850
|
+
openCommentForm(root, strip, anchor);
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// `<Comment>` attrs in emitted order — [attribute name, dataset key].
|
|
855
|
+
const COMMENT_ATTRS = [
|
|
856
|
+
["author", "commentAuthor"],
|
|
857
|
+
["file", "commentFile"],
|
|
858
|
+
["href", "commentHref"],
|
|
859
|
+
["lines", "commentLines"],
|
|
860
|
+
["severity", "commentSeverity"],
|
|
861
|
+
["side", "commentSide"],
|
|
862
|
+
["title", "commentTitle"],
|
|
863
|
+
] as const;
|
|
864
|
+
|
|
865
|
+
// A fence that survives backtick runs inside the code — one tick longer
|
|
866
|
+
// than the longest run (three minimum).
|
|
867
|
+
const fenceFor = (code: string): string => {
|
|
868
|
+
let max = 0;
|
|
869
|
+
for (const m of code.matchAll(/`+/gu)) {
|
|
870
|
+
max = Math.max(max, m[0].length);
|
|
871
|
+
}
|
|
872
|
+
return "`".repeat(Math.max(3, max + 1));
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
// Serialize a [data-comments] block back into <Comments> markup — the
|
|
876
|
+
// source form, so pasting it over the source block persists the review.
|
|
877
|
+
// The fence round-trips verbatim (lang + raw meta + text on the root's
|
|
878
|
+
// data-comments-*); every card re-emits its data-comment-* attributes, DOM
|
|
879
|
+
// order preserving thread order.
|
|
880
|
+
const commentsMarkdown = (root: HTMLElement): string => {
|
|
881
|
+
const out = ["<Comments>", ""];
|
|
882
|
+
const code = (root.dataset.commentsCode ?? "").replace(/\n$/u, "");
|
|
883
|
+
const lang = root.dataset.commentsLang ?? "";
|
|
884
|
+
const meta = root.dataset.commentsMeta ?? "";
|
|
885
|
+
if (code !== "") {
|
|
886
|
+
const fence = fenceFor(code);
|
|
887
|
+
out.push(
|
|
888
|
+
`${fence}${lang}${meta === "" ? "" : ` ${meta}`}`,
|
|
889
|
+
code,
|
|
890
|
+
fence,
|
|
891
|
+
""
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
for (const c of root.querySelectorAll("[data-mdxr-comment]")) {
|
|
895
|
+
// Hydrated <template> kids are real children — the card skeleton inside
|
|
896
|
+
// the `card` template must not serialize as a phantom comment.
|
|
897
|
+
if (!(c instanceof HTMLElement) || c.closest("template") !== null) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const d = c.dataset;
|
|
901
|
+
const attrs = COMMENT_ATTRS.map(([name, key]) => {
|
|
902
|
+
const v = d[key];
|
|
903
|
+
return v === undefined || v === "" ? "" : jsxAttr(name, v);
|
|
904
|
+
}).join("");
|
|
905
|
+
const text = d.commentText ?? "";
|
|
906
|
+
out.push(
|
|
907
|
+
text === ""
|
|
908
|
+
? `<Comment${attrs} />`
|
|
909
|
+
: `<Comment${attrs}>${mdText(text)}</Comment>`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
out.push("", "</Comments>");
|
|
913
|
+
return out.join("\n");
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// Comments clicks: "+" line buttons, thread Reply, form Cancel/Comment, the
|
|
917
|
+
// block's Copy markdown.
|
|
918
|
+
const handleCommentsClick = (el: Element): boolean => {
|
|
919
|
+
const addBtn = closestEl(el, "[data-comment-add]");
|
|
920
|
+
if (addBtn !== null) {
|
|
921
|
+
openLineComment(addBtn);
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
const replyBtn = closestEl(el, "[data-comment-reply]");
|
|
925
|
+
if (replyBtn !== null) {
|
|
926
|
+
const strip = closestEl(replyBtn, "[data-comment-strip]");
|
|
927
|
+
const root = closestEl(replyBtn, "[data-comments]");
|
|
928
|
+
if (strip !== null && root !== null) {
|
|
929
|
+
openCommentForm(root, strip, anchorOfStrip(strip));
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
const cancelBtn = closestEl(el, "[data-comment-cancel]");
|
|
934
|
+
if (cancelBtn !== null) {
|
|
935
|
+
const form = closestEl(cancelBtn, "[data-comment-form]");
|
|
936
|
+
if (form !== null) {
|
|
937
|
+
closeCommentForm(form);
|
|
938
|
+
}
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
const submitBtn = closestEl(el, "[data-comment-submit]");
|
|
942
|
+
if (submitBtn !== null) {
|
|
943
|
+
const form = closestEl(submitBtn, "[data-comment-form]");
|
|
944
|
+
if (form !== null) {
|
|
945
|
+
submitCommentForm(form);
|
|
946
|
+
}
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
const copyBtn = closestEl(el, "[data-comments-copy]");
|
|
950
|
+
if (copyBtn === null) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
const root = closestEl(copyBtn, "[data-comments]");
|
|
954
|
+
if (root !== null) {
|
|
955
|
+
copyWithFeedback(copyBtn, commentsMarkdown(root));
|
|
956
|
+
}
|
|
957
|
+
return true;
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// Comment clicks plus the form's keydown: Cmd/Ctrl+Enter submits, Esc
|
|
961
|
+
// cancels — GitHub's comment-box shortcuts.
|
|
962
|
+
const handleComments = (e: Event, el: Element): boolean => {
|
|
963
|
+
if (e.type === "click") {
|
|
964
|
+
return handleCommentsClick(el);
|
|
965
|
+
}
|
|
966
|
+
if (e.type !== "keydown" || !(e instanceof KeyboardEvent)) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
const form = closestEl(el, "[data-comment-form]");
|
|
970
|
+
if (form === null) {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
974
|
+
submitCommentForm(form);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
if (e.key === "Escape") {
|
|
978
|
+
closeCommentForm(form);
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
return false;
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// Board clicks: per-card lane move buttons and the markdown copy.
|
|
985
|
+
const handleBoardClick = (el: Element): boolean => {
|
|
986
|
+
const moveBtn = el.closest("[data-board-move]");
|
|
987
|
+
if (moveBtn instanceof HTMLButtonElement) {
|
|
988
|
+
const card = closestEl(moveBtn, "[data-board-card]");
|
|
989
|
+
if (card !== null && !moveBtn.disabled) {
|
|
990
|
+
moveCard(card, moveBtn.dataset.boardMove === "-1" ? -1 : 1);
|
|
991
|
+
}
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
const boardCopyBtn = closestEl(el, "[data-board-copy]");
|
|
995
|
+
if (boardCopyBtn === null) {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
const board = closestEl(boardCopyBtn, "[data-board]");
|
|
999
|
+
if (board !== null) {
|
|
1000
|
+
copyWithFeedback(boardCopyBtn, boardMarkdown(board));
|
|
1001
|
+
}
|
|
1002
|
+
return true;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// Card drag lifecycle + board clicks: dragstart arms `draggedCard`;
|
|
1006
|
+
// dragover marks the insertion point and allows the drop; drop moves the
|
|
1007
|
+
// card; dragend covers aborted drags (the source always gets it, drop or
|
|
1008
|
+
// not); clicks hit the move buttons or the markdown copy. Returns true
|
|
1009
|
+
// when the event was consumed.
|
|
1010
|
+
const handleBoard = (e: Event, el: Element): boolean => {
|
|
1011
|
+
if (e.type === "click") {
|
|
1012
|
+
return handleBoardClick(el);
|
|
1013
|
+
}
|
|
1014
|
+
if (e.type === "dragstart") {
|
|
1015
|
+
return dragStart(e, el);
|
|
1016
|
+
}
|
|
1017
|
+
if (e.type === "dragover") {
|
|
1018
|
+
return dragOver(e, el);
|
|
1019
|
+
}
|
|
1020
|
+
if (e.type === "drop") {
|
|
1021
|
+
return drop(e, el);
|
|
1022
|
+
}
|
|
1023
|
+
if (e.type !== "dragend" || draggedCard === null) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
dragFinish(closestEl(draggedCard, "[data-board]"));
|
|
1027
|
+
return true;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// Click handling once board/comments had their pass: generic copy, ask
|
|
1031
|
+
// copy/save, theme cycle.
|
|
1032
|
+
const handleDocClick = (el: Element): void => {
|
|
1033
|
+
const copyBtn = closestEl(el, "[data-copy]");
|
|
1034
|
+
if (copyBtn !== null) {
|
|
1035
|
+
copyWithFeedback(
|
|
1036
|
+
copyBtn,
|
|
1037
|
+
copyBtn.dataset.copy ?? "",
|
|
1038
|
+
"Copied",
|
|
1039
|
+
"Copy failed"
|
|
1040
|
+
);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const askBtn = closestEl(el, "[data-ask-copy]");
|
|
1044
|
+
if (askBtn !== null) {
|
|
1045
|
+
const box = closestEl(askBtn, "[data-ask]");
|
|
1046
|
+
if (box === null) {
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
// Re-collect on click too: autofill/programmatic edits fire no events.
|
|
1050
|
+
syncAsk(box);
|
|
1051
|
+
copyWithFeedback(askBtn, askMarkdown(box));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const askSaveBtn = closestEl(el, "[data-ask-save]");
|
|
1055
|
+
if (askSaveBtn !== null) {
|
|
1056
|
+
const box = closestEl(askSaveBtn, "[data-ask]");
|
|
1057
|
+
if (box !== null) {
|
|
1058
|
+
saveAsk(box, askSaveBtn);
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const themeBtn = closestEl(el, "[data-mdxr-theme]");
|
|
1063
|
+
if (themeBtn !== null) {
|
|
1064
|
+
cycleTheme();
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Delegated handler for the document's interactive bits. On `input`/`change`
|
|
1070
|
+
* inside an `[data-ask]` block it rewrites the block's `[data-ask-output]`
|
|
1071
|
+
* Markdown answer sheet (`- **label**: answer` lines under `# title`, built
|
|
1072
|
+
* from `data-q-label`/`data-q-type` on each `[data-mdxr-q]` wrapper). On
|
|
1073
|
+
* `click`: `[data-copy]` copies a fixed string; `[data-ask-copy]` copies the
|
|
1074
|
+
* sheet; `[data-ask-save]` downloads it as a `.md` file; `[data-board-move]`
|
|
1075
|
+
* shifts a card a lane over; `[data-board-copy]` copies the board's current
|
|
1076
|
+
* `<Board>` markup; `[data-comment-*]` covers add/reply/cancel/submit and
|
|
1077
|
+
* `[data-comments-copy]` the block's `<Comments>` markup;
|
|
1078
|
+
* `[data-mdxr-theme]` cycles the document theme auto → light → dark
|
|
1079
|
+
* (persisted to localStorage, so THEME_JS can restore it before first
|
|
1080
|
+
* paint). The `drag*` types drive the board's card drag & drop, `keydown`
|
|
1081
|
+
* the comment form's submit/cancel shortcuts. `entry.ts` registers this on
|
|
1082
|
+
* click/input/change/keydown/dragstart/dragover/drop/dragend; the Storybook
|
|
1083
|
+
* preview binds it directly.
|
|
1084
|
+
*/
|
|
1085
|
+
export const handleDocEvent = (e: Event): void => {
|
|
1086
|
+
const el = e.target;
|
|
1087
|
+
if (!(el instanceof Element)) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (handleBoard(e, el)) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (handleComments(e, el)) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
// Field edits keep the Markdown pane live; the init pass in entry.ts
|
|
1097
|
+
// seeds it by bubbling a synthetic `input` event off each [data-ask].
|
|
1098
|
+
if (e.type === "input" || e.type === "change") {
|
|
1099
|
+
const box = closestEl(el, "[data-ask]");
|
|
1100
|
+
if (box !== null) {
|
|
1101
|
+
syncAsk(box);
|
|
1102
|
+
}
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (e.type === "click") {
|
|
1106
|
+
handleDocClick(el);
|
|
1107
|
+
}
|
|
1108
|
+
};
|