Skip to content

Commit b77843a

Browse files
committed
better code highlighter initialization and loading; live relative time
1 parent ee0d2a9 commit b77843a

File tree

11 files changed

+234
-137
lines changed

11 files changed

+234
-137
lines changed

packages/jsrepl/src/app/dashboard/layout.tsx

+10-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
22
import Footer from '@/components/footer'
33
import Header from '@/components/header'
4+
import { CodeHighlighterProvider } from '@/components/providers/code-highlighter-provider'
45

56
export const dynamic = 'force-dynamic'
67

@@ -17,12 +18,14 @@ export default function DashboardLayout({
1718
children: React.ReactNode
1819
}>) {
1920
return (
20-
<div className="flex min-h-screen flex-col">
21-
<Header />
22-
<main className="flex-1">
23-
<div className="text-foreground/90 container">{children}</div>
24-
</main>
25-
<Footer className="mt-32 max-md:mt-20" />
26-
</div>
21+
<CodeHighlighterProvider>
22+
<div className="flex min-h-screen flex-col">
23+
<Header />
24+
<main className="flex-1">
25+
<div className="text-foreground/90 container">{children}</div>
26+
</main>
27+
<Footer className="mt-32 max-md:mt-20" />
28+
</div>
29+
</CodeHighlighterProvider>
2730
)
2831
}

packages/jsrepl/src/app/dashboard/recently-viewed.tsx

+5-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
'use client'
22

3-
import { useMemo } from 'react'
43
import { useQuery } from '@tanstack/react-query'
54
import { LucideTelescope } from 'lucide-react'
65
import ErrorComponent from '@/components/error'
6+
import { RelativeTime } from '@/components/relative-time'
77
import { useSupabaseClient } from '@/hooks/useSupabaseClient'
88
import { useUser } from '@/hooks/useUser'
9-
import { formatRelativeTime } from '@/lib/datetime'
109
import { loadRecentlyViewedRepls } from '@/lib/repl-stored-state/adapter-supabase'
1110
import { ReplStoredState } from '@/types'
1211
import ReplCard from './repl-card'
@@ -49,14 +48,12 @@ export function RecentlyViewed() {
4948
}
5049

5150
function RecentlyViewedTimestamp({ repl }: { repl: ReplStoredState & { viewed_at: string } }) {
52-
const viewedAtRelativeTime = useMemo(() => {
53-
return repl.viewed_at ? formatRelativeTime(new Date(repl.viewed_at)) : null
54-
}, [repl.viewed_at])
55-
5651
return (
5752
<>
58-
{viewedAtRelativeTime && (
59-
<span className="text-muted-foreground text-nowrap text-xs">{viewedAtRelativeTime}</span>
53+
{repl.viewed_at && (
54+
<span className="text-muted-foreground text-nowrap text-xs">
55+
<RelativeTime date={new Date(repl.viewed_at)} />
56+
</span>
6057
)}
6158
</>
6259
)

packages/jsrepl/src/app/dashboard/repl-card.tsx

+12-43
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
2-
import { useTheme } from 'next-themes'
1+
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
32
import Link from 'next/link'
43
import { useRouter } from 'next/navigation'
54
import { useQueryClient } from '@tanstack/react-query'
65
import { LucideGitFork, LucideLoader2, LucideTrash2 } from 'lucide-react'
7-
import { BundledLanguage, BundledTheme, codeToHtml } from 'shiki'
6+
import { BundledLanguage } from 'shiki'
87
import { toast } from 'sonner'
8+
import { RelativeTime } from '@/components/relative-time'
99
import {
1010
AlertDialog,
1111
AlertDialogAction,
@@ -19,9 +19,9 @@ import {
1919
} from '@/components/ui/alert-dialog'
2020
import { Button, buttonVariants } from '@/components/ui/button'
2121
import { UserAvatar } from '@/components/user-avatar'
22+
import { useCodeHighlighter } from '@/hooks/useCodeHighlighter'
2223
import { useSupabaseClient } from '@/hooks/useSupabaseClient'
2324
import { useUser } from '@/hooks/useUser'
24-
import { formatRelativeTime } from '@/lib/datetime'
2525
import * as ReplFS from '@/lib/repl-fs'
2626
import { fork, getPageUrl, remove } from '@/lib/repl-stored-state/adapter-supabase'
2727
import { ReplStoredState } from '@/types'
@@ -36,8 +36,8 @@ export default function ReplCard({
3636
const user = useUser()
3737
const supabase = useSupabaseClient()
3838
const queryClient = useQueryClient()
39-
const { resolvedTheme: themeId } = useTheme()
4039
const router = useRouter()
40+
const { highlightCode } = useCodeHighlighter()
4141

4242
const [isForking, setIsForking] = useState(false)
4343
const [isRemoving, setIsRemoving] = useState(false)
@@ -84,28 +84,9 @@ export default function ReplCard({
8484
}
8585
}, [filePath])
8686

87-
const [highlightedCode, setHighlightedCode] = useState<string>(escapeHtml(code))
88-
89-
useEffect(() => {
90-
if (!shikiLang) {
91-
return
92-
}
93-
94-
let disposed = false
95-
96-
codeToHtml(code, {
97-
lang: shikiLang,
98-
theme: themeId as BundledTheme,
99-
}).then((html) => {
100-
if (!disposed) {
101-
setHighlightedCode(html)
102-
}
103-
})
104-
105-
return () => {
106-
disposed = true
107-
}
108-
}, [code, shikiLang, themeId])
87+
const highlightedCode = useMemo<string>(() => {
88+
return highlightCode(code, shikiLang)
89+
}, [code, shikiLang, highlightCode])
10990

11091
async function onForkClick() {
11192
try {
@@ -186,14 +167,12 @@ export default function ReplCard({
186167
}
187168

188169
function Timestamp({ repl }: { repl: ReplStoredState }) {
189-
const updatedAtRelativeTime = useMemo(() => {
190-
return repl.updated_at ? formatRelativeTime(new Date(repl.updated_at)) : null
191-
}, [repl.updated_at])
192-
193170
return (
194171
<>
195-
{updatedAtRelativeTime && (
196-
<span className="text-muted-foreground text-nowrap text-xs">{updatedAtRelativeTime}</span>
172+
{repl.updated_at && (
173+
<span className="text-muted-foreground text-nowrap text-xs">
174+
<RelativeTime date={new Date(repl.updated_at)} />
175+
</span>
197176
)}
198177
</>
199178
)
@@ -286,13 +265,3 @@ function RemoveButton({
286265
</AlertDialog>
287266
)
288267
}
289-
290-
function escapeHtml(str: string) {
291-
const escapeMap: { [key: string]: string } = {
292-
'&': '&amp;',
293-
'<': '&lt;',
294-
'>': '&gt;',
295-
}
296-
297-
return str.replace(/[&<>]/g, (match) => escapeMap[match]!)
298-
}

packages/jsrepl/src/app/repl/[[...slug]]/components/code-editor.tsx

+38-11
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import React, { useEffect, useMemo, useRef, useState } from 'react'
1+
import React, { useEffect, useMemo, useRef } from 'react'
22
import { useTheme } from 'next-themes'
3+
import { shikiToMonaco } from '@shikijs/monaco'
34
import * as monaco from 'monaco-editor'
45
import styles from '@/components/code-editor.module.css'
56
import useCodeEditorDTS from '@/hooks/useCodeEditorDTS'
67
import useCodeEditorRepl from '@/hooks/useCodeEditorRepl'
8+
import { useCodeHighlighter } from '@/hooks/useCodeHighlighter'
79
import { useMonacoEditor } from '@/hooks/useMonacoEditor'
810
import useMonacopilot from '@/hooks/useMonacopilot'
911
import { useReplModels } from '@/hooks/useReplModels'
1012
import { useReplStoredState } from '@/hooks/useReplStoredState'
1113
import { useUserStoredState } from '@/hooks/useUserStoredState'
1214
import { PrettierFormattingProvider } from '@/lib/monaco-prettier-formatting-provider'
13-
import { loadMonacoTheme } from '@/lib/monaco-themes'
1415
import { Themes } from '@/lib/themes'
1516
import { cn } from '@/lib/utils'
1617

@@ -20,17 +21,18 @@ if (process.env.NEXT_PUBLIC_NODE_ENV === 'test' && typeof window !== 'undefined'
2021
}
2122

2223
setupMonaco()
24+
setupInitialMonacoThemes()
2325
setupTailwindCSS()
2426

2527
export default function CodeEditor() {
2628
const [replState] = useReplStoredState()
2729
const [userState] = useUserStoredState()
2830
const [editorRef, setEditor] = useMonacoEditor()
2931
const { models, readOnlyModels } = useReplModels()
32+
const { highlighter, loadedHighlightTheme } = useCodeHighlighter()
3033

3134
const containerRef = useRef<HTMLDivElement>(null)
3235

33-
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
3436
const { resolvedTheme: themeId } = useTheme()
3537
const theme = useMemo(() => Themes.find((theme) => theme.id === themeId) ?? Themes[0]!, [themeId])
3638

@@ -53,7 +55,7 @@ export default function CodeEditor() {
5355
fontSize: userState.editor.fontSize,
5456
minimap: { enabled: false },
5557
readOnly: isReadOnly,
56-
theme: theme.id,
58+
theme: loadedHighlightTheme ?? (theme.isDark ? 'initial-dark' : 'initial-light'),
5759
quickSuggestions: {
5860
other: true,
5961
comments: true,
@@ -100,12 +102,13 @@ export default function CodeEditor() {
100102
}, [userState.editor.lineNumbers, editorRef])
101103

102104
useEffect(() => {
103-
editorInitialOptions.current.theme = theme.id
104-
loadMonacoTheme(theme).then(() => {
105-
monaco.editor.setTheme(theme.id)
106-
setIsThemeLoaded(true)
107-
})
108-
}, [theme])
105+
if (highlighter && loadedHighlightTheme) {
106+
editorInitialOptions.current.theme = loadedHighlightTheme
107+
108+
shikiToMonaco(highlighter, monaco)
109+
monaco.editor.setTheme(loadedHighlightTheme)
110+
}
111+
}, [highlighter, loadedHighlightTheme])
109112

110113
useEffect(() => {
111114
const editor = monaco.editor.create(containerRef.current!, editorInitialOptions.current)
@@ -125,7 +128,7 @@ export default function CodeEditor() {
125128
return (
126129
<div
127130
ref={containerRef}
128-
className={cn('min-h-0 flex-1', styles.codeEditor, { 'opacity-0': !isThemeLoaded })}
131+
className={cn('bg-editor-background min-h-0 flex-1', styles.codeEditor)}
129132
/>
130133
)
131134
}
@@ -193,3 +196,27 @@ async function setupTailwindCSS() {
193196
},
194197
})
195198
}
199+
200+
function setupInitialMonacoThemes() {
201+
monaco.editor.defineTheme('initial-dark', {
202+
base: 'vs-dark',
203+
inherit: true,
204+
rules: [],
205+
colors: {
206+
'editor.background': '#00000000',
207+
'editor.lineHighlightBackground': '#FFFFFF0F',
208+
focusBorder: '#00000000',
209+
},
210+
})
211+
212+
monaco.editor.defineTheme('initial-light', {
213+
base: 'vs',
214+
inherit: true,
215+
rules: [],
216+
colors: {
217+
'editor.background': '#00000000',
218+
'editor.lineHighlightBackground': '#EEEEEE1F',
219+
focusBorder: '#00000000',
220+
},
221+
})
222+
}

packages/jsrepl/src/app/repl/[[...slug]]/components/info-panel-section.tsx

+6-14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react'
1+
import { useState } from 'react'
22
import { LucidePencil } from 'lucide-react'
33
import EditReplInfoDialog from '@/components/edit-repl-info-dialog'
44
import {
@@ -9,11 +9,11 @@ import {
99
PanelSectionHeaderActions,
1010
PanelSectionTrigger,
1111
} from '@/components/panel-section'
12+
import { RelativeTime } from '@/components/relative-time'
1213
import { UserAvatar } from '@/components/user-avatar'
1314
import { useReplRewindMode } from '@/hooks/useReplRewindMode'
1415
import { useReplStoredState } from '@/hooks/useReplStoredState'
1516
import { useUser } from '@/hooks/useUser'
16-
import { formatRelativeTime } from '@/lib/datetime'
1717

1818
export function InfoPanelSection({ id: sectionId }: { id: string }) {
1919
const [replState] = useReplStoredState()
@@ -25,14 +25,6 @@ export function InfoPanelSection({ id: sectionId }: { id: string }) {
2525
const isNew = !replState.id
2626
const showEditButton = isNew || (user && user.id === replState.user_id)
2727

28-
const updatedAtRelativeTime = useMemo(() => {
29-
return replState.updated_at ? formatRelativeTime(new Date(replState.updated_at)) : null
30-
}, [replState.updated_at])
31-
32-
const createdAtRelativeTime = useMemo(() => {
33-
return replState.created_at ? formatRelativeTime(new Date(replState.created_at)) : null
34-
}, [replState.created_at])
35-
3628
return (
3729
<PanelSection value={sectionId}>
3830
<PanelSectionHeader>
@@ -67,14 +59,14 @@ export function InfoPanelSection({ id: sectionId }: { id: string }) {
6759
</div>
6860
)}
6961
<div className="mt-3 space-y-1 empty:hidden">
70-
{updatedAtRelativeTime && (
62+
{replState.updated_at && (
7163
<div title={'Updated at ' + new Date(replState.updated_at).toLocaleString()}>
72-
Updated {updatedAtRelativeTime}
64+
Updated <RelativeTime date={new Date(replState.updated_at)} />
7365
</div>
7466
)}
75-
{createdAtRelativeTime && (
67+
{replState.created_at && (
7668
<div title={'Created at ' + new Date(replState.created_at).toLocaleString()}>
77-
Created {createdAtRelativeTime}
69+
Created <RelativeTime date={new Date(replState.created_at)} />
7870
</div>
7971
)}
8072
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client'
2+
3+
import { createContext, useCallback, useEffect, useState } from 'react'
4+
import { useTheme } from 'next-themes'
5+
import { BundledLanguage, BundledTheme } from 'shiki'
6+
import { ShikiHighlighter, createShikiHighlighter } from '@/lib/shiki'
7+
8+
type ContextWithNotLoadedHighlighter = {
9+
highlighter: null
10+
loadedHighlightTheme: null
11+
}
12+
13+
type ContextWithLoadedHighlighter = {
14+
highlighter: ShikiHighlighter
15+
loadedHighlightTheme: BundledTheme
16+
}
17+
18+
export type CodeHighlighterContextType = {
19+
highlightCode: (code: string, lang: BundledLanguage | null) => string
20+
} & (ContextWithNotLoadedHighlighter | ContextWithLoadedHighlighter)
21+
22+
export const CodeHighlighterContext = createContext<CodeHighlighterContextType | null>(null)
23+
24+
export function CodeHighlighterProvider({ children }: { children: React.ReactNode }) {
25+
const { resolvedTheme: themeId } = useTheme()
26+
27+
const [highlighterAndTheme, setHighlighterAndTheme] = useState<
28+
ContextWithNotLoadedHighlighter | ContextWithLoadedHighlighter
29+
>({
30+
highlighter: null,
31+
loadedHighlightTheme: null,
32+
})
33+
34+
useEffect(() => {
35+
const theme = themeId as BundledTheme | undefined
36+
if (!theme) {
37+
return
38+
}
39+
40+
createShikiHighlighter(theme).then((highlighter) => {
41+
setHighlighterAndTheme({
42+
highlighter,
43+
loadedHighlightTheme: theme,
44+
})
45+
})
46+
}, [themeId])
47+
48+
const highlightCode = useCallback(
49+
(code: string, lang: BundledLanguage | null) => {
50+
const { highlighter, loadedHighlightTheme } = highlighterAndTheme
51+
if (!highlighter || !loadedHighlightTheme || !lang) {
52+
return escapeHtml(code)
53+
}
54+
55+
return highlighter.codeToHtml(code, { lang, theme: loadedHighlightTheme })
56+
},
57+
[highlighterAndTheme]
58+
)
59+
60+
const value = {
61+
...highlighterAndTheme,
62+
highlightCode,
63+
}
64+
65+
return <CodeHighlighterContext.Provider value={value}>{children}</CodeHighlighterContext.Provider>
66+
}
67+
68+
function escapeHtml(str: string) {
69+
const escapeMap: { [key: string]: string } = {
70+
'&': '&amp;',
71+
'<': '&lt;',
72+
'>': '&gt;',
73+
}
74+
75+
return str.replace(/[&<>]/g, (match) => escapeMap[match]!)
76+
}

0 commit comments

Comments
 (0)