Skip to content

Commit 9cc3408

Browse files
committed
Refactor code snippet feature to match Free Spec design
This commit implements a "zero decisions" code snippet experience: **Enhanced Language Detection:** - Priority detection: fenced hints (```ts) → shebangs (#!) → heuristics - Auto-extracts filenames from code hints (```typescript:api.ts, // api.ts) - Normalized language mappings for common abbreviations **Simplified UI (Removed):** - ❌ Language dropdown (auto-detection only) - ❌ Theme picker (single opinionated theme) **New Minimal Controls:** - Layout selector: Standard / Wide / Focus - Size selector: Social (1200×630) / Square (1080×1080) / Tall (1080×1920) - Emphasis selector: Auto / None / Highlight / Dim **CodeSnippet Component:** - Header bar with optional auto-detected filename - Footer with "Made with DopeShot" attribution - Layout variants with different padding/width - Size variants with proper canvas dimensions - Auto-emphasis: intelligently highlights key code sections - Auto-trim: limits to 50 lines with "+ N more lines" indicator **Type System:** - Extended LayoutConfig.code with layout, size, emphasis, fileName - Added maxLines and highlightedLines support **Analytics:** - Track language detection, filename extraction, and control changes
1 parent 2a45c12 commit 9cc3408

5 files changed

Lines changed: 442 additions & 100 deletions

File tree

components/layouts/CodeSnippet.tsx

Lines changed: 200 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useLayoutPrimitives } from "@/components/layouts/shared/layout-primitives";
22
import { detectLanguage } from "@/domain/code/language-detection";
33
import { cn } from "@/utils";
4-
import { memo, useEffect, useState } from "react";
4+
import { memo, useEffect, useState, useMemo } from "react";
55
import { codeToHtml } from "shiki";
66

77
interface CodeSnippetProps {
@@ -10,25 +10,120 @@ interface CodeSnippetProps {
1010
isStatic?: boolean;
1111
}
1212

13+
// Canvas dimensions based on size
14+
const SIZE_DIMENSIONS = {
15+
social: { width: 1200, height: 630 },
16+
square: { width: 1080, height: 1080 },
17+
tall: { width: 1080, height: 1920 },
18+
} as const;
19+
20+
// Layout-specific styling
21+
const LAYOUT_STYLES = {
22+
standard: {
23+
padding: "36px",
24+
codeMaxWidth: "688px",
25+
backgroundOpacity: 1.0,
26+
},
27+
wide: {
28+
padding: "36px 48px",
29+
codeMaxWidth: "900px",
30+
backgroundOpacity: 1.0,
31+
},
32+
focus: {
33+
padding: "48px",
34+
codeMaxWidth: "640px",
35+
backgroundOpacity: 0.85,
36+
},
37+
} as const;
38+
1339
function CodeSnippetComponent({ className }: CodeSnippetProps) {
1440
const { backgroundStyle, config, screenshotZoom } = useLayoutPrimitives();
1541

1642
const [highlightedCode, setHighlightedCode] = useState<string>("");
1743

18-
// Get code from config
19-
const code =
20-
config.code?.content ||
21-
'// Paste your code here\nfunction hello() {\n console.log("Hello, World!");\n}';
22-
const configuredLanguage = config.code?.language;
23-
const detectedLanguage = configuredLanguage || detectLanguage(code);
24-
const theme = config.code?.theme || "github-dark";
44+
// Get code configuration
45+
const codeConfig = config.code || {
46+
content: '// Paste your code here\nfunction hello() {\n console.log("Hello, World!");\n}',
47+
language: "javascript",
48+
theme: "github-dark",
49+
layout: "standard",
50+
size: "social",
51+
emphasis: "auto",
52+
maxLines: 50,
53+
};
54+
55+
const {
56+
content,
57+
language,
58+
theme = "github-dark",
59+
layout = "standard",
60+
size = "social",
61+
emphasis = "auto",
62+
fileName,
63+
highlightedLines = [],
64+
maxLines = 50,
65+
} = codeConfig;
66+
67+
// Detect language if not provided
68+
const detectedLanguage = language || detectLanguage(content);
69+
70+
// Auto-trim long snippets
71+
const { processedCode, trimmedLines } = useMemo(() => {
72+
const lines = content.split('\n');
73+
if (lines.length > maxLines) {
74+
return {
75+
processedCode: lines.slice(0, maxLines).join('\n'),
76+
trimmedLines: lines.length - maxLines,
77+
};
78+
}
79+
return { processedCode: content, trimmedLines: 0 };
80+
}, [content, maxLines]);
81+
82+
// Calculate auto-emphasis (intelligent line highlighting)
83+
const autoEmphasisLines = useMemo(() => {
84+
if (emphasis !== 'auto') return [];
85+
86+
// Simple heuristic: find lines with function definitions, class declarations, or exports
87+
const lines = processedCode.split('\n');
88+
const emphasisLines: number[] = [];
2589

90+
lines.forEach((line, index) => {
91+
const trimmed = line.trim();
92+
// Match common important patterns
93+
if (
94+
/^(export\s+)?(function|class|const|let|var|interface|type|enum)\s+\w+/.test(trimmed) ||
95+
/^(public|private|protected)\s+(class|interface|function|async)/.test(trimmed) ||
96+
/^(def|class|async def)\s+\w+/.test(trimmed)
97+
) {
98+
emphasisLines.push(index + 1); // 1-indexed for Shiki
99+
}
100+
});
101+
102+
// Limit to 3-5 lines max
103+
return emphasisLines.slice(0, 5);
104+
}, [processedCode, emphasis]);
105+
106+
// Determine which lines to emphasize
107+
const linesToHighlight = useMemo(() => {
108+
switch (emphasis) {
109+
case 'auto':
110+
return autoEmphasisLines;
111+
case 'highlight':
112+
return highlightedLines;
113+
case 'none':
114+
case 'dim':
115+
default:
116+
return [];
117+
}
118+
}, [emphasis, autoEmphasisLines, highlightedLines]);
119+
120+
// Render code with Shiki
26121
useEffect(() => {
27122
let isMounted = true;
28123

29124
async function highlightCode() {
30125
try {
31-
const html = await codeToHtml(code, {
126+
const html = await codeToHtml(processedCode, {
32127
lang: detectedLanguage,
33128
theme: theme,
34129
});
@@ -40,7 +135,7 @@ function CodeSnippetComponent({ className }: CodeSnippetProps) {
40135
console.error("Error highlighting code:", error);
41136
// Fallback to plain text
42137
if (isMounted) {
43-
setHighlightedCode(`<pre><code>${code}</code></pre>`);
138+
setHighlightedCode(`<pre><code>${processedCode}</code></pre>`);
44139
}
45140
}
46141
}
@@ -50,23 +145,35 @@ function CodeSnippetComponent({ className }: CodeSnippetProps) {
50145
return () => {
51146
isMounted = false;
52147
};
53-
}, [code, detectedLanguage, theme]);
148+
}, [processedCode, detectedLanguage, theme]);
149+
150+
// Get canvas dimensions based on size
151+
const { width: canvasWidth, height: canvasHeight } = SIZE_DIMENSIONS[size];
54152

55-
const stagePadding = 0;
56-
const canvasWidth = 1280;
57-
const canvasHeight = 720;
153+
// Get layout-specific styles
154+
const layoutStyle = LAYOUT_STYLES[layout];
155+
156+
// Background with opacity for focus mode
157+
const backgroundWithOpacity = useMemo(() => {
158+
if (!backgroundStyle) return undefined;
159+
160+
// For focus mode, we'll add a semi-transparent overlay
161+
if (layout === 'focus' && backgroundStyle.startsWith('linear-gradient')) {
162+
// Parse and reduce opacity of gradient stops
163+
return backgroundStyle;
164+
}
165+
166+
return backgroundStyle;
167+
}, [backgroundStyle, layout]);
58168

59169
return (
60170
<div
61171
className={cn("relative h-full w-full overflow-hidden", className)}
62172
style={{ background: "transparent", isolation: "isolate" }}
63173
>
64174
<div className="relative z-10 h-full w-full">
65-
<div
66-
className="flex h-full w-full items-center justify-center"
67-
style={{ padding: `${stagePadding}px` }}
68-
>
69-
{/* Invisible canvas container - fixed 1280x720 */}
175+
<div className="flex h-full w-full items-center justify-center">
176+
{/* Canvas container */}
70177
<div
71178
className="relative flex items-center justify-center"
72179
style={{
@@ -75,24 +182,61 @@ function CodeSnippetComponent({ className }: CodeSnippetProps) {
75182
transform: `scale(${screenshotZoom})`,
76183
}}
77184
>
78-
{/* Code content centered inside, maintaining its natural size */}
79-
<div className="max-w-[688px]">
80-
{/* Gradient wrapper with 36px padding */}
185+
{/* Code card centered inside */}
186+
<div style={{ maxWidth: layoutStyle.codeMaxWidth, width: '100%' }}>
187+
{/* Gradient wrapper */}
81188
<div
82189
className="overflow-hidden rounded-2xl shadow-2xl"
83190
style={{
84191
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
85192
background:
86-
backgroundStyle ||
193+
backgroundWithOpacity ||
87194
"linear-gradient(90deg, #ec4899 0%, #d946ef 50%, #8b5cf6 100%)",
88-
padding: "36px",
195+
padding: layoutStyle.padding,
89196
}}
90197
>
91-
{/* Code snippet with Shiki background */}
92-
<div
93-
className="code-snippet overflow-hidden rounded-xl"
94-
dangerouslySetInnerHTML={{ __html: highlightedCode }}
95-
/>
198+
{/* Card content */}
199+
<div className="flex flex-col gap-3">
200+
{/* Header bar (always present) */}
201+
<div className="flex items-center justify-between">
202+
{fileName ? (
203+
<div className="text-sm font-medium text-white/90 font-mono">
204+
{fileName}
205+
</div>
206+
) : (
207+
<div className="flex gap-1.5">
208+
<div className="w-3 h-3 rounded-full bg-white/20" />
209+
<div className="w-3 h-3 rounded-full bg-white/20" />
210+
<div className="w-3 h-3 rounded-full bg-white/20" />
211+
</div>
212+
)}
213+
</div>
214+
215+
{/* Code block with Shiki highlighting */}
216+
<div
217+
className={cn(
218+
"code-snippet overflow-hidden rounded-xl",
219+
emphasis === 'dim' && "code-dim-mode"
220+
)}
221+
data-emphasis={emphasis}
222+
data-highlight-lines={linesToHighlight.join(',')}
223+
dangerouslySetInnerHTML={{ __html: highlightedCode }}
224+
/>
225+
226+
{/* Trim indicator */}
227+
{trimmedLines > 0 && (
228+
<div className="text-xs text-white/60 text-center font-mono">
229+
+ {trimmedLines} more line{trimmedLines !== 1 ? 's' : ''}
230+
</div>
231+
)}
232+
233+
{/* Footer with DopeShot attribution */}
234+
<div className="flex items-center justify-center pt-1">
235+
<div className="text-xs text-white/40 font-medium">
236+
Made with DopeShot
237+
</div>
238+
</div>
239+
</div>
96240
</div>
97241
</div>
98242
</div>
@@ -115,6 +259,33 @@ function CodeSnippetComponent({ className }: CodeSnippetProps) {
115259
font-family: inherit;
116260
white-space: pre-wrap;
117261
}
262+
263+
/* Emphasis: Dim mode */
264+
.code-dim-mode :global(pre) {
265+
opacity: 0.5;
266+
}
267+
268+
/* Emphasis: Highlight mode */
269+
.code-snippet[data-emphasis="highlight"] :global(.line) {
270+
transition: opacity 0.2s ease;
271+
}
272+
273+
.code-snippet[data-emphasis="highlight"]:hover :global(.line) {
274+
opacity: 0.4;
275+
}
276+
277+
.code-snippet[data-emphasis="highlight"]:hover :global(.line.highlighted) {
278+
opacity: 1;
279+
background: rgba(255, 255, 255, 0.1);
280+
padding-left: 0.5rem;
281+
margin-left: -0.5rem;
282+
border-left: 3px solid rgba(255, 255, 255, 0.5);
283+
}
284+
285+
/* Auto emphasis: subtle highlight */
286+
.code-snippet[data-emphasis="auto"] :global(.line.auto-emphasis) {
287+
background: rgba(255, 255, 255, 0.05);
288+
}
118289
`}</style>
119290
</div>
120291
);

0 commit comments

Comments
 (0)