Skip to content

Commit 49d2767

Browse files
feat(viola): Add start pane
1 parent 7897ee1 commit 49d2767

16 files changed

Lines changed: 252 additions & 116 deletions

File tree

packages/viola/src/components/layout.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { useRouter } from '@tanstack/react-router';
2+
import { invariant } from 'outvariant';
23
import { Suspense, useCallback, useEffect, useState } from 'react';
34
import { useSnapshot } from 'valtio';
45

56
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@v/ui/dialog';
67
import { SidebarProvider, SidebarTrigger } from '@v/ui/sidebar';
78
import { $ui, type PaneContent } from '../stores/ui';
8-
import { Pane } from './pane';
9+
import { Pane, panes } from './pane';
10+
import { PaneContext } from './panes/util';
911
import { Sandbox } from './sandbox';
1012
import { SideMenu } from './side-menu';
1113

@@ -49,20 +51,33 @@ function DedicatedModal() {
4951
}
5052
}, [uiSnap.dedicatedModal]);
5153

54+
if (!modalContent) {
55+
return null;
56+
}
57+
58+
const { type, ...props } = modalContent;
59+
const pane = panes[type];
60+
invariant(pane, 'Unknown pane: %s', type);
61+
62+
type Props = PanePropertyMap[typeof type];
63+
const Title = pane.title as React.ComponentType<Props>;
64+
5265
return (
53-
modalContent && (
66+
<PaneContext.Provider value={{ ...pane, props, hideTitle: true }}>
5467
<Dialog open={open} onOpenChange={closeModal}>
5568
<DialogContent className="max-w-4xl p-0" onAnimationEnd={purgeModal}>
5669
<div className="size-full max-h-svh overflow-auto grid gap-4 p-6">
5770
<DialogHeader>
58-
<DialogTitle>{modalContent.title()}</DialogTitle>
71+
<DialogTitle>
72+
<Title {...props} />
73+
</DialogTitle>
5974
</DialogHeader>
6075

6176
<Pane content={modalContent} />
6277
</div>
6378
</DialogContent>
6479
</Dialog>
65-
)
80+
</PaneContext.Provider>
6681
);
6782
}
6883

Lines changed: 37 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,47 @@
1-
import { Suspense } from 'react';
1+
import { invariant } from 'outvariant';
2+
import { Suspense, useContext } from 'react';
23

34
import { Loader2 } from '@v/ui/icon';
45
import type { PaneContent } from '../stores/ui';
5-
import { Bibliography } from './panes/bibliography';
6-
import { Edit } from './panes/edit';
7-
import { Preview } from './panes/preview';
8-
import { Theme } from './panes/theme';
6+
import { PaneContext, type PaneDefinition } from './panes/util';
97

10-
function ScrollOverflow({ children }: React.PropsWithChildren<object>) {
11-
return (
12-
<div className="size-full overflow-auto overscroll-contain scrollbar-stable">
13-
{children}
14-
</div>
15-
);
16-
}
8+
export const panes = Object.fromEntries(
9+
Object.entries(
10+
import.meta.glob('./panes/pane.*', {
11+
eager: true,
12+
import: 'Pane',
13+
}),
14+
).map(([path, module]) => {
15+
const matched = path.match(/pane\.(\w+)\.tsx$/);
16+
invariant(matched, 'Unknown pane: %s', path);
17+
return [matched[1], module];
18+
}),
19+
) as {
20+
[K in keyof PanePropertyMap]: PaneDefinition<PanePropertyMap[K]>;
21+
};
1722

18-
function PaneContainer({
19-
content,
20-
children,
21-
}: React.PropsWithChildren<{ content: PaneContent }>) {
22-
return (
23-
<div className="pt-16 pb-8 px-8 max-w-xl mx-auto grid gap-4">
24-
<h2 className="text-2xl font-bold">{content.title()}</h2>
25-
<div>{children}</div>
26-
</div>
27-
);
28-
}
23+
export function Pane({
24+
content: { type, ...props },
25+
}: {
26+
content: PaneContent;
27+
}) {
28+
const pane = panes[type];
29+
invariant(pane, 'Unknown pane: %s', type);
30+
type Props = PanePropertyMap[typeof type];
31+
const Component = pane.content as React.ComponentType<Props>;
32+
const parentContext = useContext(PaneContext);
2933

30-
export function Pane({ content }: { content: PaneContent }) {
3134
return (
32-
<Suspense
33-
fallback={
34-
<div className="grid place-items-center size-full">
35-
<Loader2 className="animate-spin size-12 text-gray-300" />
36-
</div>
37-
}
38-
>
39-
{(() => {
40-
switch (content.type) {
41-
case 'bibliography':
42-
return (
43-
<ScrollOverflow>
44-
<PaneContainer {...{ content }}>
45-
<Bibliography />
46-
</PaneContainer>
47-
</ScrollOverflow>
48-
);
49-
case 'edit':
50-
return (
51-
<ScrollOverflow>
52-
<Edit {...content} />
53-
</ScrollOverflow>
54-
);
55-
case 'preview':
56-
return <Preview />;
57-
case 'theme':
58-
return (
59-
<ScrollOverflow>
60-
<PaneContainer {...{ content }}>
61-
<Theme />
62-
</PaneContainer>
63-
</ScrollOverflow>
64-
);
65-
default:
66-
return null;
35+
<PaneContext.Provider value={{ ...pane, props, ...(parentContext ?? {}) }}>
36+
<Suspense
37+
fallback={
38+
<div className="grid place-items-center size-full">
39+
<Loader2 className="animate-spin size-12 text-gray-300" />
40+
</div>
6741
}
68-
})()}
69-
</Suspense>
42+
>
43+
<Component {...(props as Props)} />
44+
</Suspense>
45+
</PaneContext.Provider>
7046
);
7147
}

packages/viola/src/components/panes/edit.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/viola/src/components/panes/bibliography.tsx renamed to packages/viola/src/components/panes/pane.bibliography.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ import {
1818
useLiveSelectField,
1919
} from '../../hooks/use-live-field';
2020
import { $project } from '../../stores/project';
21+
import { createPane, PaneContainer, ScrollOverflow } from './util';
22+
23+
type BibliographyPaneProperty = object;
24+
25+
declare global {
26+
interface PanePropertyMap {
27+
bibliography: BibliographyPaneProperty;
28+
}
29+
}
30+
31+
export const Pane = createPane<BibliographyPaneProperty>({
32+
title: () => 'Bibliography',
33+
content: (props) => (
34+
<ScrollOverflow>
35+
<PaneContainer>
36+
<Content {...props} />
37+
</PaneContainer>
38+
</ScrollOverflow>
39+
),
40+
});
2141

2242
function BookTitleInput({
2343
children,
@@ -128,7 +148,7 @@ function TocSectionDepthSelect({
128148
);
129149
}
130150

131-
export function Bibliography() {
151+
function Content(_: BibliographyPaneProperty) {
132152
use($project.setupPromise);
133153

134154
const projectSnap = useSnapshot($project);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { lazy } from 'react';
2+
import { useSnapshot } from 'valtio';
3+
4+
import { $content, type ContentId } from '../../stores/content';
5+
import { $project } from '../../stores/project';
6+
import { createPane, ScrollOverflow } from './util';
7+
8+
type EditPaneProperty = { contentId: ContentId };
9+
10+
declare global {
11+
interface PanePropertyMap {
12+
edit: EditPaneProperty;
13+
}
14+
}
15+
16+
export const Pane = createPane<EditPaneProperty>({
17+
title: Title,
18+
content: (props) => (
19+
<ScrollOverflow>
20+
<Content {...props} />
21+
</ScrollOverflow>
22+
),
23+
});
24+
25+
const ContentEditor = lazy(() =>
26+
$project.setupPromise.then(() => import('../content-editor')),
27+
);
28+
29+
function Title({ contentId }: EditPaneProperty) {
30+
const content = useSnapshot($content);
31+
const file = content.files.get(contentId);
32+
return file ? `Content Editor: File ${file.filename}` : `Content Editor`;
33+
}
34+
35+
function Content({ contentId }: EditPaneProperty) {
36+
return <ContentEditor contentId={contentId} />;
37+
}

packages/viola/src/components/panes/preview.tsx renamed to packages/viola/src/components/panes/pane.preview.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,26 @@ import { ref } from 'valtio';
33

44
import { $project } from '../../stores/project';
55
import { $viewer } from '../../stores/viewer';
6+
import { createPane } from './util';
7+
8+
type PreviewPaneProperty = object;
9+
10+
declare global {
11+
interface PanePropertyMap {
12+
preview: PreviewPaneProperty;
13+
}
14+
}
15+
16+
export const Pane = createPane<PreviewPaneProperty>({
17+
title: () => 'Preview',
18+
content: (props) => <Content {...props} />,
19+
});
620

721
const iframeRef = (el: HTMLIFrameElement | null) => {
822
$viewer.iframeElement = el ? ref(el) : undefined;
923
};
1024

11-
const PreviewIframe = () => {
25+
function Content(_: PreviewPaneProperty) {
1226
use($project.setupPromise);
1327

1428
const url = use($viewer.setupServer());
@@ -22,8 +36,4 @@ const PreviewIframe = () => {
2236
sandbox="allow-same-origin allow-scripts allow-modals"
2337
/>
2438
);
25-
};
26-
27-
export const Preview = () => {
28-
return <PreviewIframe />;
29-
};
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createPane, PaneContainer, ScrollOverflow } from './util';
2+
3+
type StartPaneProperty = object;
4+
5+
declare global {
6+
interface PanePropertyMap {
7+
start: StartPaneProperty;
8+
}
9+
}
10+
11+
export const Pane = createPane<StartPaneProperty>({
12+
title: () => 'Start',
13+
content: (props) => (
14+
<ScrollOverflow>
15+
<PaneContainer>
16+
<Content {...props} />
17+
</PaneContainer>
18+
</ScrollOverflow>
19+
),
20+
hideTitle: true,
21+
});
22+
23+
function Content(_: StartPaneProperty) {
24+
return <div className="grid gap-4">Start Page</div>;
25+
}

packages/viola/src/components/panes/theme.tsx renamed to packages/viola/src/components/panes/pane.theme.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ import { installTheme } from '../../stores/actions/install-theme';
1010
import { $project } from '../../stores/project';
1111
import { $sandbox } from '../../stores/sandbox';
1212
import { $theme } from '../../stores/theme';
13+
import { createPane, PaneContainer, ScrollOverflow } from './util';
14+
15+
type ThemePaneProperty = object;
16+
17+
declare global {
18+
interface PanePropertyMap {
19+
theme: ThemePaneProperty;
20+
}
21+
}
22+
23+
export const Pane = createPane<ThemePaneProperty>({
24+
title: () => 'Customize Theme',
25+
content: (props) => (
26+
<ScrollOverflow>
27+
<PaneContainer>
28+
<Content {...props} />
29+
</PaneContainer>
30+
</ScrollOverflow>
31+
),
32+
});
1333

1434
const CodeEditor = lazy(() => import('../code-editor'));
1535

@@ -37,7 +57,7 @@ function LoadingIcon({ className, ...props }: React.ComponentProps<'span'>) {
3757
);
3858
}
3959

40-
export function Theme() {
60+
function Content(_: ThemePaneProperty) {
4161
use($project.setupPromise);
4262

4363
const themeSnap = useSnapshot($theme);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { invariant } from 'outvariant';
2+
import { createContext, useContext } from 'react';
3+
4+
import { cn } from '@v/ui/lib/utils';
5+
6+
export interface PaneDefinition<P> {
7+
title: React.ComponentType<P>;
8+
content: React.ComponentType<P>;
9+
hideTitle?: boolean;
10+
}
11+
12+
export function createPane<P, D = PaneDefinition<P>>(definition: D): D {
13+
return definition;
14+
}
15+
16+
export const PaneContext = createContext<
17+
// biome-ignore lint/suspicious/noExplicitAny: any
18+
(PaneDefinition<any> & { props: any }) | null
19+
>(null);
20+
21+
export function ScrollOverflow({ children }: React.PropsWithChildren<object>) {
22+
return (
23+
<div className="size-full overflow-auto overscroll-contain scrollbar-stable">
24+
{children}
25+
</div>
26+
);
27+
}
28+
29+
export function PaneContainer({ children }: React.PropsWithChildren) {
30+
const context = useContext(PaneContext);
31+
invariant(context, 'PaneContext not found');
32+
33+
return (
34+
<div
35+
className={cn(
36+
'pb-8 px-8 max-w-xl mx-auto grid gap-4',
37+
context.hideTitle ? 'pt-8' : 'pt-16',
38+
)}
39+
>
40+
<h2 className={cn('text-2xl font-bold', context.hideTitle && 'sr-only')}>
41+
<context.title {...context.props} />
42+
</h2>
43+
<div>{children}</div>
44+
</div>
45+
);
46+
}

0 commit comments

Comments
 (0)