@honeydeck/honeydeck 0.4.0 → 0.5.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/DEVELOPMENT.md +4 -1
- package/Readme.md +2 -2
- package/SPEC.md +3 -3
- package/docs/components-browser-frame.md +34 -0
- package/docs/components-keyboard.md +31 -0
- package/docs/components-list-style.md +49 -0
- package/docs/components-notes.md +36 -0
- package/docs/components-reveal-group.md +58 -0
- package/docs/components-reveal-with.md +37 -0
- package/docs/components-reveal.md +33 -0
- package/docs/components-timeline-steps.md +48 -0
- package/docs/components.md +13 -54
- package/docs/configuration.md +11 -0
- package/docs/deeper-dive.md +30 -7
- package/docs/getting-started.md +2 -2
- package/docs/navigation.md +1 -1
- package/docs/pdf-export.md +4 -2
- package/docs/presenter-mode.md +6 -3
- package/docs/skills.md +3 -3
- package/docs/slidev-migration.md +3 -0
- package/docs/steps-and-reveals.md +143 -8
- package/package.json +4 -1
- package/skills/SPEC.md +2 -2
- package/skills/honeydeck/SKILL.md +2 -2
- package/skills/slidev-migration/SKILL.md +1 -0
- package/src/SPEC.md +8 -3
- package/src/cli/SPEC.md +3 -2
- package/src/cli/pdf.ts +11 -4
- package/src/remark/SPEC.md +102 -2
- package/src/remark/code-utils.ts +151 -0
- package/src/remark/shiki-code-blocks.ts +329 -136
- package/src/remark/step-numbering.ts +408 -103
- package/src/runtime/Deck.tsx +133 -116
- package/src/runtime/EffectiveColorModeContext.tsx +37 -0
- package/src/runtime/SPEC.md +21 -8
- package/src/runtime/SlideCanvas.tsx +19 -16
- package/src/runtime/SlideScaleContext.tsx +23 -0
- package/src/runtime/components/CodeBlock.tsx +19 -202
- package/src/runtime/components/CodeBlockCopyButton.tsx +64 -0
- package/src/runtime/components/CodeBlockShared.ts +17 -0
- package/src/runtime/components/Fade.tsx +51 -0
- package/src/runtime/components/FadeGroup.tsx +175 -0
- package/src/runtime/components/FadeWith.tsx +54 -0
- package/src/runtime/components/MagicCodeBlock.tsx +223 -0
- package/src/runtime/components/NavBar.tsx +1 -1
- package/src/runtime/components/NormalCodeBlock.tsx +128 -0
- package/src/runtime/components/Reveal.tsx +27 -27
- package/src/runtime/components/RevealGroup.tsx +143 -41
- package/src/runtime/components/RevealWith.tsx +63 -0
- package/src/runtime/components/SPEC.md +112 -7
- package/src/runtime/components/TimelineReveal.tsx +81 -0
- package/src/runtime/components/index.ts +13 -5
- package/src/runtime/components/timelineVisibility.ts +45 -0
- package/src/runtime/index.ts +9 -1
- package/src/runtime/navigation.ts +6 -4
- package/src/runtime/presentationApi.ts +449 -0
- package/src/runtime/views/PresenterCastButton.tsx +39 -0
- package/src/runtime/views/PresenterView.tsx +21 -4
- package/src/runtime/views/SPEC.md +7 -5
- package/src/theme/base.css +67 -2
- package/src/vite-plugin/SPEC.md +20 -2
- package/src/vite-plugin/index.ts +16 -2
- package/src/vite-plugin/splitter.ts +1 -0
- package/src/vite-plugin/virtual-modules.ts +16 -6
|
@@ -1,210 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* and timeline-driven step-through.
|
|
4
|
-
*
|
|
5
|
-
* ### Usage
|
|
6
|
-
* This component is **not imported directly by slide authors**. The
|
|
7
|
-
* `remarkShikiCodeBlocks` remark plugin automatically replaces fenced code
|
|
8
|
-
* blocks in MDX with `<HoneydeckCodeBlock>` elements at compile time.
|
|
9
|
-
*
|
|
10
|
-
* ### Props
|
|
11
|
-
* - `html` — Pre-highlighted HTML string from shiki (full `<pre>…</pre>`).
|
|
12
|
-
* - `stepsJson` — JSON-encoded `StepGroup[]` (e.g. `[[2],[4,5],"all"]`).
|
|
13
|
-
* Empty array = no step-through.
|
|
14
|
-
* - `startAt` — 1-based timeline step at which group 1 activates.
|
|
15
|
-
* Group 0 is the baseline active highlight; 0 for non-stepped blocks.
|
|
16
|
-
* - `source` — Original fenced code text for clipboard copying.
|
|
17
|
-
*
|
|
18
|
-
* ### Step-through mechanics
|
|
19
|
-
* The component reads `stepIndex` from `useTimeline()` and computes the active
|
|
20
|
-
* group index: group 0 before `startAt`, then later groups from `startAt`.
|
|
21
|
-
* It then adds `data-dim="1"` and an inline opacity style to non-highlighted
|
|
22
|
-
* `.line` spans in the rendered HTML.
|
|
23
|
-
* Base CSS also targets the attribute as a fallback.
|
|
24
|
-
*
|
|
25
|
-
* The highlighted HTML is injected via `dangerouslySetInnerHTML`, so the
|
|
26
|
-
* step-specific `data-dim` attributes are computed as part of the HTML string
|
|
27
|
-
* before React writes it to the DOM.
|
|
28
|
-
*/
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { CodeBlockCopyButton } from "./CodeBlockCopyButton.tsx";
|
|
29
3
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { useTimeline } from "../TimelineContext.tsx";
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Types
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** A single step-through group: line numbers (1-based) or 'all'. */
|
|
39
|
-
export type StepGroup = number[] | "all";
|
|
40
|
-
|
|
41
|
-
type HoneydeckCodeBlockProps = {
|
|
42
|
-
/** Full shiki HTML output (includes the `<pre>` element). */
|
|
43
|
-
html: string;
|
|
44
|
-
/** JSON-encoded StepGroup[] — empty array = no step-through. */
|
|
45
|
-
stepsJson: string;
|
|
46
|
-
/** 1-based timeline step where group 1 activates (0 = no step-through). */
|
|
47
|
-
startAt: number;
|
|
48
|
-
/** Original fenced code text copied by the hover/focus copy control. */
|
|
4
|
+
type CodeBlockProps = {
|
|
5
|
+
/** Original source copied by the hover/focus copy control. */
|
|
49
6
|
source?: string;
|
|
7
|
+
/** Extra classes for a specific code block implementation. */
|
|
8
|
+
className?: string;
|
|
9
|
+
children: ReactNode;
|
|
50
10
|
};
|
|
51
11
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const LINE_CLASS = /\bclass=(["'])[^"']*\bline\b[^"']*\1/;
|
|
55
|
-
const DATA_LINE = /\bdata-line=(["'])(\d+)\1/;
|
|
56
|
-
const STYLE_ATTR = /\sstyle=(["'])(.*?)\1/g;
|
|
57
|
-
const DIM_STYLE_DECL = "opacity: var(--honeydeck-code-line-dim-opacity);";
|
|
58
|
-
|
|
59
|
-
async function writeClipboardText(text: string): Promise<boolean> {
|
|
60
|
-
try {
|
|
61
|
-
if (navigator.clipboard) {
|
|
62
|
-
await navigator.clipboard.writeText(text);
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
} catch {
|
|
66
|
-
// Fall back to the textarea path below.
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const textarea = document.createElement("textarea");
|
|
71
|
-
textarea.value = text;
|
|
72
|
-
textarea.setAttribute("readonly", "");
|
|
73
|
-
textarea.style.position = "fixed";
|
|
74
|
-
textarea.style.inset = "0";
|
|
75
|
-
textarea.style.opacity = "0";
|
|
76
|
-
document.body.appendChild(textarea);
|
|
77
|
-
textarea.select();
|
|
78
|
-
const didCopy = document.execCommand("copy");
|
|
79
|
-
document.body.removeChild(textarea);
|
|
80
|
-
return didCopy;
|
|
81
|
-
} catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function removeDimStyle(html: string): string {
|
|
87
|
-
return html.replace(STYLE_ATTR, (match, quote: string, style: string) => {
|
|
88
|
-
if (!style.includes(DIM_STYLE_DECL)) return match;
|
|
89
|
-
|
|
90
|
-
const cleanedStyle = style
|
|
91
|
-
.replace(DIM_STYLE_DECL, "")
|
|
92
|
-
.replace(/\s{2,}/g, " ")
|
|
93
|
-
.trim();
|
|
94
|
-
|
|
95
|
-
return cleanedStyle ? ` style=${quote}${cleanedStyle}${quote}` : "";
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function addDimAttributes(attrs: string): string {
|
|
100
|
-
if (attrs.includes(DIM_STYLE_DECL)) return `${attrs} data-dim="1"`;
|
|
101
|
-
|
|
102
|
-
const withStyle = attrs.replace(
|
|
103
|
-
STYLE_ATTR,
|
|
104
|
-
(_match, quote: string, style: string) =>
|
|
105
|
-
` style=${quote}${style.trim()}${style.trim().endsWith(";") ? "" : ";"} ${DIM_STYLE_DECL}${quote}`,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
if (withStyle !== attrs) return `${withStyle} data-dim="1"`;
|
|
109
|
-
|
|
110
|
-
return `${attrs} data-dim="1" style="${DIM_STYLE_DECL}"`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function applyCodeStepDimming(
|
|
114
|
-
html: string,
|
|
115
|
-
steps: StepGroup[],
|
|
116
|
-
stepIndex: number,
|
|
117
|
-
startAt: number,
|
|
118
|
-
): string {
|
|
119
|
-
const cleanHtml = removeDimStyle(html.replace(DATA_DIM_ATTR, ""));
|
|
120
|
-
|
|
121
|
-
if (steps.length === 0) return cleanHtml;
|
|
122
|
-
|
|
123
|
-
let activeGroupIndex = 0;
|
|
124
|
-
if (startAt > 0 && stepIndex >= startAt) {
|
|
125
|
-
activeGroupIndex = Math.min(stepIndex - startAt + 1, steps.length - 1);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const activeGroup = steps[activeGroupIndex];
|
|
129
|
-
if (!activeGroup || activeGroup === "all") return cleanHtml;
|
|
130
|
-
|
|
131
|
-
return cleanHtml.replace(LINE_SPAN, (match, attrs: string) => {
|
|
132
|
-
if (!LINE_CLASS.test(attrs)) return match;
|
|
133
|
-
|
|
134
|
-
const dataLine = attrs.match(DATA_LINE);
|
|
135
|
-
if (!dataLine) return match;
|
|
136
|
-
|
|
137
|
-
const lineNumber = parseInt(dataLine[2] ?? "", 10);
|
|
138
|
-
if (activeGroup.includes(lineNumber)) return match;
|
|
139
|
-
|
|
140
|
-
return `<span${addDimAttributes(attrs)}>`;
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Component
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Renders a pre-highlighted code block and applies timeline-driven line
|
|
150
|
-
* dimming for step-through walkthroughs.
|
|
151
|
-
*
|
|
152
|
-
* Export name matches the identifier injected by `remarkShikiCodeBlocks`:
|
|
153
|
-
* `import { HoneydeckCodeBlock } from '@honeydeck/honeydeck/components/code-block'`
|
|
154
|
-
*/
|
|
155
|
-
export function HoneydeckCodeBlock({
|
|
156
|
-
html,
|
|
157
|
-
stepsJson,
|
|
158
|
-
startAt,
|
|
159
|
-
source,
|
|
160
|
-
}: HoneydeckCodeBlockProps) {
|
|
161
|
-
const { stepIndex } = useTimeline();
|
|
162
|
-
const [copied, setCopied] = useState(false);
|
|
163
|
-
|
|
164
|
-
// Parse step groups once (stepsJson is static — set at compile time)
|
|
165
|
-
const steps = useMemo((): StepGroup[] => {
|
|
166
|
-
try {
|
|
167
|
-
return JSON.parse(stepsJson) as StepGroup[];
|
|
168
|
-
} catch {
|
|
169
|
-
return [];
|
|
170
|
-
}
|
|
171
|
-
}, [stepsJson]);
|
|
172
|
-
|
|
173
|
-
const dimmedHtml = useMemo(
|
|
174
|
-
() => applyCodeStepDimming(html, steps, stepIndex, startAt),
|
|
175
|
-
[html, startAt, stepIndex, steps],
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
async function copySource() {
|
|
179
|
-
if (!source) return;
|
|
180
|
-
|
|
181
|
-
if (await writeClipboardText(source)) {
|
|
182
|
-
setCopied(true);
|
|
183
|
-
window.setTimeout(() => setCopied(false), 1600);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
12
|
+
/** Shared frame for Honeydeck code block implementations. */
|
|
13
|
+
export function CodeBlock({ source, className, children }: CodeBlockProps) {
|
|
187
14
|
return (
|
|
188
|
-
<div
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<CheckIcon aria-hidden="true" />
|
|
199
|
-
) : (
|
|
200
|
-
<CopyIcon aria-hidden="true" />
|
|
201
|
-
)}
|
|
202
|
-
</button>
|
|
203
|
-
) : null}
|
|
204
|
-
<div
|
|
205
|
-
// biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki generates this highlighted HTML during MDX compilation.
|
|
206
|
-
dangerouslySetInnerHTML={{ __html: dimmedHtml }}
|
|
207
|
-
/>
|
|
15
|
+
<div
|
|
16
|
+
className={[
|
|
17
|
+
"honeydeck-code-block group relative mb-[0.75em] overflow-hidden rounded-honeydeck font-mono text-[length:var(--honeydeck-font-size-code)]",
|
|
18
|
+
className,
|
|
19
|
+
]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join(" ")}
|
|
22
|
+
>
|
|
23
|
+
<CodeBlockCopyButton source={source} />
|
|
24
|
+
{children}
|
|
208
25
|
</div>
|
|
209
26
|
);
|
|
210
27
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
async function writeClipboardText(text: string): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
if (navigator.clipboard) {
|
|
7
|
+
await navigator.clipboard.writeText(text);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
} catch {
|
|
11
|
+
// Fall back to the textarea path below.
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const textarea = document.createElement("textarea");
|
|
16
|
+
textarea.value = text;
|
|
17
|
+
textarea.setAttribute("readonly", "");
|
|
18
|
+
textarea.style.position = "fixed";
|
|
19
|
+
textarea.style.inset = "0";
|
|
20
|
+
textarea.style.opacity = "0";
|
|
21
|
+
document.body.appendChild(textarea);
|
|
22
|
+
textarea.select();
|
|
23
|
+
const didCopy = document.execCommand("copy");
|
|
24
|
+
document.body.removeChild(textarea);
|
|
25
|
+
return didCopy;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type CodeBlockCopyButtonProps = {
|
|
32
|
+
source?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function CodeBlockCopyButton({ source }: CodeBlockCopyButtonProps) {
|
|
36
|
+
const [copied, setCopied] = useState(false);
|
|
37
|
+
|
|
38
|
+
async function copySource() {
|
|
39
|
+
if (!source) return;
|
|
40
|
+
|
|
41
|
+
if (await writeClipboardText(source)) {
|
|
42
|
+
setCopied(true);
|
|
43
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!source) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
className="honeydeck-code-copy-button"
|
|
52
|
+
type="button"
|
|
53
|
+
aria-label={copied ? "Code copied" : "Copy code"}
|
|
54
|
+
title={copied ? "Copied" : "Copy code"}
|
|
55
|
+
onClick={copySource}
|
|
56
|
+
>
|
|
57
|
+
{copied ? (
|
|
58
|
+
<CheckIcon aria-hidden="true" />
|
|
59
|
+
) : (
|
|
60
|
+
<CopyIcon aria-hidden="true" />
|
|
61
|
+
)}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A single step-through group: line numbers (1-based) or 'all'. */
|
|
2
|
+
export type StepGroup = number[] | "all";
|
|
3
|
+
|
|
4
|
+
export function parseJsonProp<T>(
|
|
5
|
+
value: string,
|
|
6
|
+
fallback: T,
|
|
7
|
+
propName?: string,
|
|
8
|
+
): T {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(value) as T;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (propName) {
|
|
13
|
+
console.warn(`Honeydeck Magic Code could not parse ${propName}.`, error);
|
|
14
|
+
}
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useTimelineVisibility } from "./timelineVisibility.ts";
|
|
3
|
+
|
|
4
|
+
export type FadeProps = {
|
|
5
|
+
/** The step index at which this content fades out. */
|
|
6
|
+
at?: number;
|
|
7
|
+
/** Wrapper element. Injected by the compiler from MDX context. */
|
|
8
|
+
as?: "div" | "span";
|
|
9
|
+
/** Additional CSS class for custom transition overrides. */
|
|
10
|
+
className?: string;
|
|
11
|
+
/** Remove hidden content from the DOM/layout instead of reserving space. */
|
|
12
|
+
ephemeral?: boolean;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Timeline-driven fade component for content that starts visible and disappears
|
|
18
|
+
* once the slide reaches its assigned step.
|
|
19
|
+
*/
|
|
20
|
+
export function Fade({
|
|
21
|
+
as: Component = "div",
|
|
22
|
+
at = 1,
|
|
23
|
+
className = "",
|
|
24
|
+
ephemeral = false,
|
|
25
|
+
children,
|
|
26
|
+
}: FadeProps) {
|
|
27
|
+
const { shouldRender, style } = useTimelineVisibility({
|
|
28
|
+
mode: "fade",
|
|
29
|
+
at,
|
|
30
|
+
ephemeral,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!shouldRender) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Component
|
|
37
|
+
className={[
|
|
38
|
+
"honeydeck-fade mb-[0.75em] text-[length:var(--honeydeck-font-size-body)] leading-[1.6] [&>:last-child]:mb-0",
|
|
39
|
+
className,
|
|
40
|
+
]
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join(" ")}
|
|
43
|
+
style={{
|
|
44
|
+
display: Component === "span" ? "inline" : "block",
|
|
45
|
+
...style,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</Component>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
cloneElement,
|
|
5
|
+
isValidElement,
|
|
6
|
+
type Key,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useTimeline } from "../TimelineContext.tsx";
|
|
11
|
+
import { Fade } from "./Fade.tsx";
|
|
12
|
+
|
|
13
|
+
type ElementWithCommonProps = ReactElement<{
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
export type FadeGroupProps = {
|
|
20
|
+
/** The step index for the first child. Subsequent children increment by 1. */
|
|
21
|
+
at?: number;
|
|
22
|
+
/** Internal compiler-provided absolute steps for each direct fade target. */
|
|
23
|
+
targetStepsJson?: string;
|
|
24
|
+
/** Remove hidden children from the DOM/layout instead of reserving space. */
|
|
25
|
+
ephemeral?: boolean;
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function isMeaningfulReactChild(child: ReactNode): boolean {
|
|
30
|
+
return typeof child === "string" ? child.trim().length > 0 : child !== null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toMeaningfulArray(children: ReactNode): ReactNode[] {
|
|
34
|
+
return Children.toArray(children).filter(isMeaningfulReactChild);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isListElement(child: ReactNode): child is ElementWithCommonProps {
|
|
38
|
+
return (
|
|
39
|
+
isValidElement(child) &&
|
|
40
|
+
typeof child.type === "string" &&
|
|
41
|
+
(child.type === "ul" || child.type === "ol")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isElementWithCommonProps(
|
|
46
|
+
child: ReactNode,
|
|
47
|
+
): child is ElementWithCommonProps {
|
|
48
|
+
return isValidElement(child);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function childKey(child: ReactNode, fallback: string): Key {
|
|
52
|
+
return isValidElement(child) && child.key != null ? child.key : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fadeVisibility(
|
|
56
|
+
stepIndex: number,
|
|
57
|
+
at: number,
|
|
58
|
+
showFutureSteps: boolean,
|
|
59
|
+
futureStepOpacity: number,
|
|
60
|
+
ephemeral: boolean,
|
|
61
|
+
): { shouldRender: boolean; style: CSSProperties } {
|
|
62
|
+
const visible = stepIndex < at;
|
|
63
|
+
const previewFuture = !visible && showFutureSteps;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
shouldRender: visible || previewFuture || !ephemeral,
|
|
67
|
+
style: {
|
|
68
|
+
visibility: visible || previewFuture ? "visible" : "hidden",
|
|
69
|
+
opacity: visible ? 1 : previewFuture ? futureStepOpacity : 0,
|
|
70
|
+
transition: "opacity 300ms ease",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseTargetSteps(targetStepsJson: string | undefined): number[] {
|
|
76
|
+
if (!targetStepsJson) return [];
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(targetStepsJson) as unknown;
|
|
80
|
+
if (!Array.isArray(parsed)) return [];
|
|
81
|
+
return parsed.filter((value): value is number => typeof value === "number");
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Fades each meaningful direct child out as a separate timeline step. */
|
|
88
|
+
export function FadeGroup({
|
|
89
|
+
at = 1,
|
|
90
|
+
targetStepsJson,
|
|
91
|
+
ephemeral = false,
|
|
92
|
+
children,
|
|
93
|
+
}: FadeGroupProps) {
|
|
94
|
+
const { stepIndex, showFutureSteps, futureStepOpacity } = useTimeline();
|
|
95
|
+
const fadeTargets = toMeaningfulArray(children);
|
|
96
|
+
const targetSteps = parseTargetSteps(targetStepsJson);
|
|
97
|
+
let targetIndex = 0;
|
|
98
|
+
let nextAt = at;
|
|
99
|
+
|
|
100
|
+
function nextTargetAt(): number {
|
|
101
|
+
const explicitAt = targetSteps[targetIndex];
|
|
102
|
+
targetIndex++;
|
|
103
|
+
|
|
104
|
+
if (explicitAt !== undefined) {
|
|
105
|
+
nextAt = Math.max(nextAt, explicitAt + 1);
|
|
106
|
+
return explicitAt;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fallbackAt = nextAt;
|
|
110
|
+
nextAt++;
|
|
111
|
+
return fallbackAt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
{fadeTargets.map((child, _index) => {
|
|
117
|
+
if (isListElement(child)) {
|
|
118
|
+
const listItems = toMeaningfulArray(child.props.children);
|
|
119
|
+
const listKey = childKey(child, `fade-list-${at}-${targetIndex}`);
|
|
120
|
+
const renderedListItems = listItems.map((listItem) => {
|
|
121
|
+
const itemAt = nextTargetAt();
|
|
122
|
+
const itemKey = childKey(listItem, `fade-item-${itemAt}`);
|
|
123
|
+
|
|
124
|
+
if (!isElementWithCommonProps(listItem)) {
|
|
125
|
+
return (
|
|
126
|
+
<Fade key={itemKey} at={itemAt} ephemeral={ephemeral}>
|
|
127
|
+
{listItem}
|
|
128
|
+
</Fade>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { shouldRender, style } = fadeVisibility(
|
|
133
|
+
stepIndex,
|
|
134
|
+
itemAt,
|
|
135
|
+
showFutureSteps,
|
|
136
|
+
futureStepOpacity,
|
|
137
|
+
ephemeral,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!shouldRender) return null;
|
|
141
|
+
|
|
142
|
+
return cloneElement(listItem, {
|
|
143
|
+
key: itemKey,
|
|
144
|
+
style: {
|
|
145
|
+
...listItem.props.style,
|
|
146
|
+
...style,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (ephemeral && renderedListItems.every((item) => item === null)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return cloneElement(child, {
|
|
156
|
+
key: listKey,
|
|
157
|
+
children: renderedListItems,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const childAt = nextTargetAt();
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Fade
|
|
165
|
+
key={childKey(child, `fade-child-${childAt}`)}
|
|
166
|
+
at={childAt}
|
|
167
|
+
ephemeral={ephemeral}
|
|
168
|
+
>
|
|
169
|
+
{child}
|
|
170
|
+
</Fade>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useTimelineVisibility } from "./timelineVisibility.ts";
|
|
3
|
+
|
|
4
|
+
export type FadeWithProps = {
|
|
5
|
+
/** Slide-local reveal target name or existing slide-local timeline step. */
|
|
6
|
+
target?: string | number;
|
|
7
|
+
/** Existing slide-local timeline step to fade with. */
|
|
8
|
+
at?: number;
|
|
9
|
+
/** Wrapper element. Injected by the compiler from MDX context. */
|
|
10
|
+
as?: "div" | "span";
|
|
11
|
+
/** Additional CSS class for custom transition overrides. */
|
|
12
|
+
className?: string;
|
|
13
|
+
/** Remove hidden content from the DOM/layout instead of reserving space. */
|
|
14
|
+
ephemeral?: boolean;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Fade content out at an explicit target step without creating a timeline step. */
|
|
19
|
+
export function FadeWith({
|
|
20
|
+
as: Component = "div",
|
|
21
|
+
at,
|
|
22
|
+
target,
|
|
23
|
+
className = "",
|
|
24
|
+
ephemeral = false,
|
|
25
|
+
children,
|
|
26
|
+
}: FadeWithProps) {
|
|
27
|
+
const fadeAt = typeof target === "number" ? target : (at ?? 1);
|
|
28
|
+
const { shouldRender, style } = useTimelineVisibility({
|
|
29
|
+
mode: "fade",
|
|
30
|
+
at: fadeAt,
|
|
31
|
+
target: fadeAt,
|
|
32
|
+
ephemeral,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!shouldRender) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Component
|
|
39
|
+
className={[
|
|
40
|
+
"honeydeck-fade honeydeck-fade-with mb-[0.75em] text-[length:var(--honeydeck-font-size-body)] leading-[1.6] [&>:last-child]:mb-0",
|
|
41
|
+
className,
|
|
42
|
+
]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join(" ")}
|
|
45
|
+
style={{
|
|
46
|
+
display: Component === "span" ? "inline" : "block",
|
|
47
|
+
...style,
|
|
48
|
+
}}
|
|
49
|
+
data-honeydeck-fade-with={typeof target === "string" ? target : undefined}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</Component>
|
|
53
|
+
);
|
|
54
|
+
}
|