diff --git a/docs/demo/uno.config.ts b/docs/demo/uno.config.ts index b73d3593d..b42aeaffd 100644 --- a/docs/demo/uno.config.ts +++ b/docs/demo/uno.config.ts @@ -22,9 +22,10 @@ const customIconCollection = iconPaths.reduce( export default defineConfig({ ...unoCSSConfig, content: { - inline: globSync( + inline: globSync([ `${convertPathToPattern(join(require.resolve('@tutorialkit/components-react'), '..'))}/**/*.js`, - ).map((filePath) => { + `${convertPathToPattern(join(require.resolve('@tutorialkit/astro'), '..'))}/default/**/*.astro`, + ]).map((filePath) => { return () => fs.readFile(filePath, { encoding: 'utf8' }); }), }, diff --git a/packages/astro/src/default/components/MainContainer.astro b/packages/astro/src/default/components/MainContainer.astro new file mode 100644 index 000000000..57102884a --- /dev/null +++ b/packages/astro/src/default/components/MainContainer.astro @@ -0,0 +1,80 @@ +--- +import { NavWrapper as Nav } from './NavWrapper'; +import { WorkspacePanelWrapper as WorkspacePanel } from './WorkspacePanelWrapper'; +import TutorialContent from './TutorialContent.astro'; +import ResizablePanel from './ResizablePanel.astro'; +import MobileContentToggle from './MobileContentToggle.astro'; +import { RESIZABLE_PANELS } from '../utils/constants'; +import type { Lesson, NavList } from '@tutorialkit/types'; +import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; +import { hasWorkspace } from '../utils/workspace'; + +interface Props { + lesson: Lesson; + navList: NavList; +} + +const { lesson, navList } = Astro.props; + +const showWorkspacePanel = hasWorkspace(lesson); +--- + + +
+
+
+ +
+
+ + diff --git a/packages/astro/src/default/components/MobileContentToggle.astro b/packages/astro/src/default/components/MobileContentToggle.astro new file mode 100644 index 000000000..c55e18109 --- /dev/null +++ b/packages/astro/src/default/components/MobileContentToggle.astro @@ -0,0 +1,44 @@ +
+ + + + + + diff --git a/packages/astro/src/default/components/ResizablePanel.astro b/packages/astro/src/default/components/ResizablePanel.astro index d9f59b52d..bde547898 100644 --- a/packages/astro/src/default/components/ResizablePanel.astro +++ b/packages/astro/src/default/components/ResizablePanel.astro @@ -1,4 +1,6 @@ --- +import { classNames } from '@tutorialkit/components-react'; + export type Type = 'horizontal' | 'vertical'; export type Priority = 'min' | 'max'; @@ -8,11 +10,25 @@ interface Props { pos?: string; min?: string; max?: string; - priority?: 'min' | 'max'; - classList?: string; + class?: string; + sidePanelClass?: string; +} + +export interface IResizablePanel { + mainPanel(): HTMLElement; + sidePanel(): HTMLElement | undefined; + divider(): HTMLElement | undefined; } -let { id, type = 'horizontal', min = '0%', pos = '50%', max = '100%', priority = 'min', classList } = Astro.props; +let { + id, + type = 'horizontal', + min = '0%', + pos = '50%', + max = '100%', + class: className = '', + sidePanelClass = '', +} = Astro.props; // check if there is a `slot` defined with name `b` const hasSidePanel = Astro.slots.has('b'); @@ -23,18 +39,19 @@ if (!hasSidePanel) { min = '100%'; max = '100%'; } + +const panelClass = classNames('overflow-hidden', { 'h-full': type === 'horizontal' }); --- - -
+ +
-
+
{ hasSidePanel && ( <> -
+
-
+
) } @@ -68,12 +91,11 @@ if (!hasSidePanel) { - - diff --git a/packages/astro/src/default/components/webcontainer.ts b/packages/astro/src/default/components/webcontainer.ts index 1a2332ee0..4a55c5d76 100644 --- a/packages/astro/src/default/components/webcontainer.ts +++ b/packages/astro/src/default/components/webcontainer.ts @@ -1,12 +1,12 @@ // must be imported first import { useAuth } from './setup.js'; -import { TutorialStore } from '@tutorialkit/runtime'; +import { safeBoot, TutorialStore, webContainerBootStatus } from '@tutorialkit/runtime'; import { auth, WebContainer } from '@webcontainer/api'; import { joinPaths } from '../utils/url.js'; interface WebContainerContext { - useAuth: boolean; + readonly useAuth: boolean; loggedIn: () => Promise; loaded: boolean; } @@ -16,9 +16,7 @@ export let webcontainer: Promise = new Promise(() => { }); if (!import.meta.env.SSR) { - webcontainer = Promise.resolve(useAuth ? auth.loggedIn() : null).then(() => - WebContainer.boot({ workdirName: 'tutorial' }), - ); + webcontainer = Promise.resolve(useAuth ? auth.loggedIn() : null).then(() => safeBoot({ workdirName: 'tutorial' })); webcontainer.then(() => { webcontainerContext.loaded = true; @@ -41,6 +39,10 @@ export function logout() { auth.logout({ ignoreRevokeError: true }); } +export function forceBoot() { + webContainerBootStatus().unblock(); +} + export const webcontainerContext: WebContainerContext = { useAuth, loggedIn: () => auth.loggedIn(), diff --git a/packages/astro/src/default/layouts/Layout.astro b/packages/astro/src/default/layouts/Layout.astro index 7a6517c6c..93a175aed 100644 --- a/packages/astro/src/default/layouts/Layout.astro +++ b/packages/astro/src/default/layouts/Layout.astro @@ -11,7 +11,7 @@ const baseURL = import.meta.env.BASE_URL; --- - + @@ -51,6 +51,9 @@ const baseURL = import.meta.env.BASE_URL; if (newMain && oldMain) { builtInSwap.swapBodyElement(newMain, oldMain); + + // delete extraneous route announcer + document.querySelector('.astro-route-announcer')?.remove(); } else { // fallback to built-in body swap semantics builtInSwap.swapBodyElement(newDocument.body, document.body); @@ -76,7 +79,7 @@ const baseURL = import.meta.env.BASE_URL; } - + diff --git a/packages/astro/src/default/pages/[...slug].astro b/packages/astro/src/default/pages/[...slug].astro index c687e27d5..b94dfcfed 100644 --- a/packages/astro/src/default/pages/[...slug].astro +++ b/packages/astro/src/default/pages/[...slug].astro @@ -1,16 +1,11 @@ --- import type { InferGetStaticPropsType } from 'astro'; import Header from '../components/Header.astro'; -import { NavWrapper as Nav } from '../components/NavWrapper'; -import ResizablePanel from '../components/ResizablePanel.astro'; -import TutorialContent from '../components/TutorialContent.astro'; -import { WorkspacePanelWrapper as WorkspacePanel } from '../components/WorkspacePanelWrapper'; +import MainContainer from '../components/MainContainer.astro'; import Layout from '../layouts/Layout.astro'; import '../styles/base.css'; import '@tutorialkit/custom.css'; -import { RESIZABLE_PANELS } from '../utils/constants'; import { generateStaticRoutes } from '../utils/routes'; -import { hasWorkspace } from '../utils/workspace'; export async function getStaticPaths() { return generateStaticRoutes(); @@ -19,29 +14,12 @@ export async function getStaticPaths() { type Props = InferGetStaticPropsType; const { lesson, logoLink, navList, title } = Astro.props as Props; - -const showWorkspacePanel = hasWorkspace(lesson); ---
- -
-
-
- -
-
+
diff --git a/packages/astro/src/default/stores/view-store.ts b/packages/astro/src/default/stores/view-store.ts new file mode 100644 index 000000000..deab91498 --- /dev/null +++ b/packages/astro/src/default/stores/view-store.ts @@ -0,0 +1,5 @@ +import { atom } from 'nanostores'; + +export type View = 'content' | 'editor'; + +export const viewStore = atom('content'); diff --git a/packages/components/react/src/Nav.tsx b/packages/components/react/src/Nav.tsx index 3dbc1d35a..01aa86fde 100644 --- a/packages/components/react/src/Nav.tsx +++ b/packages/components/react/src/Nav.tsx @@ -52,10 +52,10 @@ export function Nav({ lesson: currentLesson, navList }: Props) { onClick={() => setShowDropdown(!showDropdown)} >
- {currentLesson.part.title} - / - {currentLesson.chapter.title} - / + {currentLesson.part.title} + / + {currentLesson.chapter.title} + / {currentLesson.data.title}
{ + // we update the iframes position at max fps if we have any + if (hasPreviews) { + return requestAnimationFrameLoop(onResize); + } + + return undefined; + }, [hasPreviews]); + adjustLength(iframeRefs.current, activePreviewsCount, newIframeRef); preparePreviewsContainer(activePreviewsCount); @@ -106,7 +115,6 @@ export const PreviewPanel = memo( void; preview: PreviewInfo; previewCount: number; first?: boolean; @@ -135,7 +142,7 @@ interface PreviewProps { toggleTerminal?: () => void; } -function Preview({ preview, iframe, onResize, previewCount, first, last, toggleTerminal }: PreviewProps) { +function Preview({ preview, iframe, previewCount, first, last, toggleTerminal }: PreviewProps) { const previewContainerRef = useRef(null); useEffect(() => { @@ -150,15 +157,6 @@ function Preview({ preview, iframe, onResize, previewCount, first, last, toggleT } }, [preview.url, iframe.ref]); - useEffect(() => { - const resizeObserver = new ResizeObserver(onResize); - resizeObserver.observe(previewContainerRef.current!); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - return (
void): () => void { + let handle: number; + + const callback = () => { + loop(); + handle = requestAnimationFrame(callback); + }; + + handle = requestAnimationFrame(callback); + + return () => { + cancelAnimationFrame(handle); + }; +} + function previewTitle(preview: PreviewInfo, previewCount: number) { if (preview.title) { return preview.title; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 71a520716..fe4e01898 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,4 +1,5 @@ export { LessonFilesFetcher } from './lesson-files.js'; export { TutorialRunner } from './tutorial-runner.js'; export type { Command, Commands, PreviewInfo, Step, Steps } from './webcontainer/index.js'; +export { safeBoot, webContainerBootStatus } from './webcontainer/index.js'; export { TutorialStore } from './store/index.js'; diff --git a/packages/runtime/src/webcontainer/index.ts b/packages/runtime/src/webcontainer/index.ts index 079129c40..daad6f80a 100644 --- a/packages/runtime/src/webcontainer/index.ts +++ b/packages/runtime/src/webcontainer/index.ts @@ -1,3 +1,4 @@ export { Command, Commands } from './command.js'; export { PreviewInfo } from './preview-info.js'; export { type Step, type Steps, StepsController } from './steps.js'; +export { safeBoot, webContainerBootStatus } from './on-demand-boot.js'; diff --git a/packages/runtime/src/webcontainer/on-demand-boot.ts b/packages/runtime/src/webcontainer/on-demand-boot.ts new file mode 100644 index 000000000..b643eee6c --- /dev/null +++ b/packages/runtime/src/webcontainer/on-demand-boot.ts @@ -0,0 +1,55 @@ +/** + * Lightweight wrapper around WebContainer.boot + * to only boot if there's no risk on crashing the browser. + * + * This typicall might happen on iOS. + * + * When iOS is detected, a call to `unblock()` is required + * to move forward with the boot. + */ +import { WebContainer, type BootOptions } from '@webcontainer/api'; +import { withResolvers } from '../utils/promises.js'; + +let blocked: undefined | boolean; + +const blockingStatus = withResolvers(); + +export async function safeBoot(options: BootOptions) { + if (typeof blocked === 'undefined') { + blocked = isRestricted(); + } + + if (blocked) { + await blockingStatus.promise; + } + + return WebContainer.boot(options); +} + +interface BootStatus { + readonly blocked: boolean | undefined; + unblock(): void; +} + +export function webContainerBootStatus(): BootStatus { + return { + blocked, + unblock() { + if (blocked === false) { + return; + } + + blocked = false; + blockingStatus.resolve(); + }, + }; +} + +function isRestricted() { + const { userAgent, maxTouchPoints, platform } = navigator; + + const iOS = /iPhone/.test(userAgent) || platform === 'iPhone'; + const iPadOS = (platform === 'MacIntel' && maxTouchPoints > 1) || platform === 'iPad'; + + return iOS || iPadOS; +} diff --git a/packages/template/uno.config.ts b/packages/template/uno.config.ts index b73d3593d..b42aeaffd 100644 --- a/packages/template/uno.config.ts +++ b/packages/template/uno.config.ts @@ -22,9 +22,10 @@ const customIconCollection = iconPaths.reduce( export default defineConfig({ ...unoCSSConfig, content: { - inline: globSync( + inline: globSync([ `${convertPathToPattern(join(require.resolve('@tutorialkit/components-react'), '..'))}/**/*.js`, - ).map((filePath) => { + `${convertPathToPattern(join(require.resolve('@tutorialkit/astro'), '..'))}/default/**/*.astro`, + ]).map((filePath) => { return () => fs.readFile(filePath, { encoding: 'utf8' }); }), },