UNPKG

@live-demo/core

Version:

Core components for @live-demo plugins.

687 lines (650 loc) 22.8 kB
import { useDebouncedCallback, useElementSize, useFullscreen, useLocalStorage } from "@mantine/hooks"; import { createContext, createElement, useCallback, useContext, useEffect, useState } from "react"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import clsx from "clsx"; import { IconBrandVscode, IconCode, IconEye, IconMaximize, IconMinimize, IconTextWrap, IconTextWrapDisabled } from "@tabler/icons-react"; import { javascript } from "@codemirror/lang-javascript"; import { vscodeDark, vscodeLight } from "@uiw/codemirror-theme-vscode"; import ReactCodeMirror, { EditorView } from "@uiw/react-codemirror"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import getImport from "_live_demo_virtual_modules"; import { ErrorBoundary } from "react-error-boundary"; //#region src/web/context/parseProps.ts /** * Parse props, as they come JSON.stringified. * Without stringification having code strings (props.files) in MDX tends to break things. */ function parseProps(props) { return Object.fromEntries(Object.entries(props).map(([key, value]) => { return [key, JSON.parse(value)]; })); } //#endregion //#region src/web/context/LiveDemoProvider.tsx const LiveDemoContext = createContext(void 0); function LiveDemoProvider(props) { const fullscreen = useFullscreen(); const pluginProps = parseProps(props.pluginProps); const [files, setFiles] = useState(pluginProps.files); const [activeFile, setActiveFile] = useState(pluginProps.entryFileName); const updateFiles = useCallback((update) => { setFiles((prevFiles) => ({ ...prevFiles, ...update })); }, []); return /* @__PURE__ */ jsx(LiveDemoContext.Provider, { value: { files, setFiles, updateFiles, activeFile, setActiveFile, fullscreen, isDark: props.isDark, options: pluginProps.options, entryFileName: pluginProps.entryFileName }, children: props.children }); } const useLiveDemoContext = () => { const context = useContext(LiveDemoContext); if (context === void 0) throw new Error("useLiveDemoContext must be used within a LiveDemoProvider"); return context; }; //#endregion //#region src/web/hooks/useActiveCode.ts const useActiveCode = () => { const { files, activeFile, updateFiles } = useLiveDemoContext(); const code = files[activeFile] ?? ""; const updateCode = useCallback((code$1) => { updateFiles({ [activeFile]: code$1 }); }, [activeFile, updateFiles]); return { code, updateCode }; }; //#endregion //#region src/web/ui/components/Button/Button.module.css?css_module const classes$6 = { "button": "pb1Bmq_button", "text": "pb1Bmq_text" }; var Button_module_default = classes$6; const _button0$1 = classes$6["button"]; const _text0 = classes$6["text"]; //#endregion //#region src/web/ui/components/Button/Button.tsx const Button = ({ icon, text, children, className,...restProps }) => { const classes$7 = clsx(className, Button_module_default.button); return /* @__PURE__ */ jsxs("button", { className: classes$7, ...restProps, children: [ icon, " ", text && /* @__PURE__ */ jsx("span", { className: Button_module_default.text, children: text }), children ] }); }; //#endregion //#region src/web/constants/localStorage.ts let LocalStorage = /* @__PURE__ */ function(LocalStorage$1) { LocalStorage$1["PanelsView"] = "live-demo-panels-view"; LocalStorage$1["WrapCode"] = "live-demo-wrap-code"; return LocalStorage$1; }({}); //#endregion //#region src/web/constants/settings.ts let PanelsView = /* @__PURE__ */ function(PanelsView$1) { PanelsView$1["Preview"] = "Preview"; PanelsView$1["Editor"] = "Editor"; PanelsView$1["Split"] = "Split view"; return PanelsView$1; }({}); //#endregion //#region src/web/hooks/useLocalStorage.ts const useLocalStorageView = () => { return useLocalStorage({ defaultValue: PanelsView.Split, key: LocalStorage.PanelsView }); }; const useLocalStorageWrapCode = () => { return useLocalStorage({ defaultValue: false, key: LocalStorage.WrapCode }); }; //#endregion //#region src/web/ui/components/ToggleButtonGroup/ToggleButtonGroup.module.css?css_module const classes$5 = { "button": "_2sAijW_button", "wrapper": "_2sAijW_wrapper" }; var ToggleButtonGroup_module_default = classes$5; const _button0 = classes$5["button"]; const _wrapper0$5 = classes$5["wrapper"]; //#endregion //#region src/web/ui/components/ToggleButtonGroup/ToggleButtonGroup.tsx const ToggleButtonGroup = (props) => { const { values, setValue, activeValue } = props; return /* @__PURE__ */ jsx("div", { className: ToggleButtonGroup_module_default.wrapper, children: values.map((entry) => { const label = typeof entry === "object" ? entry.label : entry; const value = typeof entry === "object" ? entry.value : entry; return /* @__PURE__ */ jsx(Button, { title: typeof label === "string" ? label : value, className: ToggleButtonGroup_module_default.button, "data-active": value === activeValue, onClick: () => setValue(value), children: label }, value); }) }); }; //#endregion //#region src/web/ui/controlPanel/LiveDemoControlPanel/ButtonFullscreen.tsx const ButtonFullscreen = () => { const { fullscreen } = useLiveDemoContext(); const Icon = fullscreen.fullscreen ? IconMinimize : IconMaximize; const text = fullscreen.fullscreen ? "Exit fullscreen" : "Fullscreen"; return /* @__PURE__ */ jsx(Button, { text, icon: /* @__PURE__ */ jsx(Icon, {}), title: "Toggle fullscreen", onClick: fullscreen.toggle }); }; //#endregion //#region src/web/ui/controlPanel/LiveDemoControlPanel/ButtonWrapCode.tsx const ButtonWrapCode = () => { const [wrapped, setWrapped] = useLocalStorageWrapCode(); const toggleWrapped = () => { setWrapped(!wrapped); }; const Icon = wrapped ? IconTextWrap : IconTextWrapDisabled; return /* @__PURE__ */ jsx(Button, { icon: /* @__PURE__ */ jsx(Icon, {}), title: "Toggle code wrap", onClick: toggleWrapped }); }; //#endregion //#region src/web/ui/controlPanel/LiveDemoControlPanel/LiveDemoControlPanel.module.css?css_module const classes$4 = { "wrapper": "nUtczG_wrapper", "section": "nUtczG_section" }; var LiveDemoControlPanel_module_default = classes$4; const _wrapper0$4 = classes$4["wrapper"]; const _section0 = classes$4["section"]; //#endregion //#region src/web/ui/controlPanel/LiveDemoControlPanel/labels.tsx const getPanelViewsValues = (showIcons) => [ { value: PanelsView.Split, label: showIcons ? /* @__PURE__ */ jsx(IconBrandVscode, {}) : PanelsView.Split }, { value: PanelsView.Preview, label: showIcons ? /* @__PURE__ */ jsx(IconEye, {}) : PanelsView.Preview }, { value: PanelsView.Editor, label: showIcons ? /* @__PURE__ */ jsx(IconCode, {}) : PanelsView.Editor } ]; //#endregion //#region src/web/ui/controlPanel/LiveDemoControlPanel/LiveDemoControlPanel.tsx const NARROW_THRESHOLD = 340; const LiveDemoControlPanel = () => { const { options } = useLiveDemoContext(); const wrapperEl = useElementSize(); const isNarrow = wrapperEl.width < NARROW_THRESHOLD; const [panelsView, setPanelsView] = useLocalStorageView(); if (options?.controlPanel?.hide) return null; return /* @__PURE__ */ jsxs("div", { ref: wrapperEl.ref, className: LiveDemoControlPanel_module_default.wrapper, "data-icon-buttons": isNarrow, children: [/* @__PURE__ */ jsx("div", { className: LiveDemoControlPanel_module_default.section, children: /* @__PURE__ */ jsx(ToggleButtonGroup, { values: getPanelViewsValues(isNarrow), activeValue: panelsView, setValue: setPanelsView }) }), /* @__PURE__ */ jsxs("div", { className: LiveDemoControlPanel_module_default.section, children: [/* @__PURE__ */ jsx(ButtonWrapCode, {}), /* @__PURE__ */ jsx(ButtonFullscreen, {})] })] }); }; //#endregion //#region src/web/ui/editor/LiveDemoEditor/LiveDemoEditor.tsx const LiveDemoEditor = (props) => { const { options, isDark } = useLiveDemoContext(); const mergedOptions = Object.assign(options?.editor ?? {}, props); const theme = isDark ? vscodeDark : vscodeLight; const { code, updateCode } = useActiveCode(); const [lineWrap] = useLocalStorageWrapCode(); return /* @__PURE__ */ jsx(ReactCodeMirror, { value: code, onChange: updateCode, theme, extensions: [lineWrap ? EditorView.lineWrapping : [], javascript({ jsx: true, typescript: true })], basicSetup: { lineNumbers: false, foldGutter: false, autocompletion: false, tabSize: 2 }, ...mergedOptions }); }; //#endregion //#region src/web/ui/editor/LiveDemoFileTabs/LiveDemoFileTabs.module.css?css_module const classes$3 = { "tab": "uk_pJq_tab", "wrapper": "uk_pJq_wrapper" }; var LiveDemoFileTabs_module_default = classes$3; const _tab0 = classes$3["tab"]; const _wrapper0$3 = classes$3["wrapper"]; //#endregion //#region src/web/ui/editor/LiveDemoFileTabs/LiveDemoFileTabs.tsx const LiveDemoFileTabs = (props) => { const { files, activeFile, setActiveFile, options } = useLiveDemoContext(); const mergedOptions = Object.assign(options?.fileTabs ?? {}, props); const { hideSingleTab } = mergedOptions; const fileNames = Object.keys(files); if (mergedOptions.hide || hideSingleTab && fileNames.length === 1) return null; return /* @__PURE__ */ jsx("div", { className: LiveDemoFileTabs_module_default.wrapper, children: fileNames.map((name) => { return /* @__PURE__ */ jsx(Button, { className: LiveDemoFileTabs_module_default.tab, "data-active": name === activeFile, onClick: () => { setActiveFile(name); }, children: name }, name); }) }); }; //#endregion //#region src/web/ui/liveDemo/LiveDemoCore/LiveDemoCore.tsx const LiveDemoCore = (props) => { return /* @__PURE__ */ jsx(LiveDemoProvider, { ...props, children: /* @__PURE__ */ jsxs(LiveDemoWrapper, { children: [/* @__PURE__ */ jsx(LiveDemoControlPanel, {}), /* @__PURE__ */ jsx(LiveDemoResizablePanels, {})] }) }); }; //#endregion //#region src/web/ui/liveDemo/LiveDemoResizablePanels/LiveDemoResizablePanels.module.css?css_module const classes$2 = { "vertical": "cXERBa_vertical", "hiddenPanel": "cXERBa_hiddenPanel", "resizeHandle": "cXERBa_resizeHandle", "editorPanel": "cXERBa_editorPanel", "wrapper": "cXERBa_wrapper" }; var LiveDemoResizablePanels_module_default = classes$2; const _vertical0 = classes$2["vertical"]; const _hiddenPanel0 = classes$2["hiddenPanel"]; const _resizeHandle0 = classes$2["resizeHandle"]; const _editorPanel0 = classes$2["editorPanel"]; const _wrapper0$2 = classes$2["wrapper"]; //#endregion //#region src/web/ui/liveDemo/LiveDemoResizablePanels/LiveDemoResizablePanels.tsx const LiveDemoResizablePanels = (props) => { const { options } = useLiveDemoContext(); const mergedOptions = Object.assign(options?.resizablePanels ?? {}, props); const { classes: classes$7, autoSaveId, verticalThreshold = 550, defaultPanelSizes = { editor: 50, preview: 50 } } = mergedOptions; const [panelsView] = useLocalStorageView(); const wrapperSize = useElementSize(); const isVertical = wrapperSize.width < verticalThreshold; const wrapperClass = clsx(LiveDemoResizablePanels_module_default.wrapper, classes$7?.wrapper, { [LiveDemoResizablePanels_module_default.vertical]: isVertical }); const editorClasses = clsx(LiveDemoResizablePanels_module_default.editorPanel, classes$7?.editorPanel, { [LiveDemoResizablePanels_module_default.hiddenPanel]: panelsView === PanelsView.Preview }); const previewClasses = clsx(classes$7?.previewPanel, { [LiveDemoResizablePanels_module_default.hiddenPanel]: panelsView === PanelsView.Editor }); return /* @__PURE__ */ jsx("div", { className: wrapperClass, ref: wrapperSize.ref, children: /* @__PURE__ */ jsxs(PanelGroup, { autoSaveId, style: { flexDirection: isVertical ? "column-reverse" : "row" }, direction: isVertical ? "vertical" : "horizontal", children: [ /* @__PURE__ */ jsx(Panel, { id: "editor", className: editorClasses, defaultSize: defaultPanelSizes.editor, order: isVertical ? 1 : 0, onKeyDown: (e) => { e.stopPropagation(); }, children: props.editor ?? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(LiveDemoFileTabs, {}), /* @__PURE__ */ jsx(LiveDemoEditor, {})] }) }), /* @__PURE__ */ jsx(PanelResizeHandle, { className: LiveDemoResizablePanels_module_default.resizeHandle }), /* @__PURE__ */ jsx(Panel, { id: "preview", className: previewClasses, defaultSize: defaultPanelSizes.preview, order: isVertical ? 0 : 1, children: props.preview ?? /* @__PURE__ */ jsx(LiveDemoPreview, {}) }) ] }) }); }; //#endregion //#region src/web/ui/liveDemo/LiveDemoWrapper/LiveDemoWrapper.module.css?css_module const classes$1 = { "wrapper": "Qvyw9q_wrapper" }; var LiveDemoWrapper_module_default = classes$1; const _wrapper0$1 = classes$1["wrapper"]; //#endregion //#region src/web/ui/liveDemo/LiveDemoWrapper/LiveDemoWrapper.tsx const LiveDemoWrapper = (props) => { const { fullscreen } = useLiveDemoContext(); return /* @__PURE__ */ jsx("div", { ref: fullscreen.ref, className: clsx(LiveDemoWrapper_module_default.wrapper, props.className), children: props.children }); }; //#endregion //#region src/shared/constants.ts let LiveDemoLanguage = /* @__PURE__ */ function(LiveDemoLanguage$1) { LiveDemoLanguage$1["ts"] = "ts"; LiveDemoLanguage$1["tsx"] = "tsx"; LiveDemoLanguage$1["js"] = "js"; LiveDemoLanguage$1["jsx"] = "jsx"; return LiveDemoLanguage$1; }({}); //#endregion //#region src/shared/pathHelpers.ts /** starting with ./ or ../ */ const relativeImportRegex = /^\.{1,2}\//; const isRelativeImport = (importPath) => relativeImportRegex.test(importPath); const stripRelativeImport = (importPath) => importPath.replace(/[./]+/, ""); const getFileExt = (filename) => filename.split(".")[1]; const getPossiblePaths = (filePath) => { const fileExt = getFileExt(filePath); if (fileExt in LiveDemoLanguage) return [filePath]; if (!fileExt) return Object.keys(LiveDemoLanguage).map((ext) => `${filePath}.${ext}`); throw new Error(`Couldn't resolve \`${filePath}\`.\nOnly .jsx and .tsx files are supported`); }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/babel/babelTransformCode.ts const babelTransformCode = (code, filename) => { const { availablePresets, transform } = window.Babel; const presets = [[availablePresets.react, { pure: false }]]; if (filename.endsWith(".tsx")) presets.push([availablePresets.typescript, { allExtensions: true, isTSX: true }]); const fileResult = transform(code, { presets }); return fileResult?.code ?? code; }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/rollup/pluginBabelTransform.ts /** * Rollup requires plugins to handle JSX/TSX, * but they depend on node and don't work in the browser. * Using @babel/standalone to transform JSX/TSX into JS * */ const pluginBabelTransform = () => { return { name: "babel-transform", transform(code, filename) { const transformedCode = babelTransformCode(code, filename); return { code: transformedCode, map: null }; } }; }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/constants.ts const GET_IMPORT_FN = "__get_import"; const EXPORTS_OBJ = "exports.default"; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/babel/babelPluginTraverse.ts const babelPluginTraverse = () => { let hasReactImported = false; return { pre() { hasReactImported = false; }, visitor: { ImportDeclaration(path) { const pkg = path.node.source.value; const code = []; const namedImports = []; for (const specifier of path.node.specifiers) { if (specifier.local.name === "React") hasReactImported = true; if (specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") { const isDefault = specifier.type === "ImportDefaultSpecifier"; const node = createGetImportDeclaration({ pkg, isDefault, imported: specifier.local.name }); code.push(node); } if (specifier.type === "ImportSpecifier") if ("name" in specifier.imported && specifier.imported.name !== specifier.local.name) namedImports.push(`${specifier.imported.name}: ${specifier.local.name}`); else namedImports.push(specifier.local.name); } if (namedImports.length > 0) { const imported = `{ ${namedImports.join(", ")} }`; const node = createGetImportDeclaration({ pkg, imported }); code.push(node); } path.replaceWithMultiple(code); }, ExportSpecifier(path) { path.parentPath.replaceWithSourceString(`${EXPORTS_OBJ} = ${path.node.local.name}`); } }, post(file) { if (!hasReactImported) { const node = createGetImportDeclaration({ pkg: "react", imported: "React", isDefault: true }); file.ast.program.body.unshift(node); } } }; }; function getParsedVariableDeclaration(code) { const parsed = window.Babel?.packages.parser.parse(code); return parsed.program.body[0]; } function createGetImportDeclaration({ pkg, imported, isDefault = false }) { const getImport$1 = `${GET_IMPORT_FN}('${pkg}', ${isDefault})`; const importString = `const ${imported} = ${getImport$1}`; return getParsedVariableDeclaration(importString); } //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/rollup/pluginBabelTransformImportsExports.ts /** * Transforms bundled code: * - replaces external imports with calls to getImport helper * which uses _live_demo_virtual_modules to resolve them * - updates export to always use `exports.default` * which is then used to get the component function */ const pluginBabelTransformImportsExports = () => { const { transform } = window.Babel; return { name: "babel-transform-imports-exports", renderChunk(code, _chunk, _options, _meta) { const fileResult = transform(code, { sourceType: "module", plugins: [babelPluginTraverse()] }); return { code: fileResult?.code ?? code, map: null }; } }; }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/rollup/pluginResolveModules.ts /** * Resolve and load in-memory files to be bundled * * Based off of @link https://rollupjs.org/faqs/#how-do-i-run-rollup-itself-in-a-browser * */ const pluginResolveModules = (files) => { return { name: "resolve-modules", resolveId(source) { if (Object.hasOwn(files, source)) return source; if (isRelativeImport(source)) { const fileName = stripRelativeImport(source); const pathsToCheck = getPossiblePaths(fileName); for (const checkedPath of pathsToCheck) if (Object.hasOwn(files, checkedPath)) return checkedPath; } return null; }, load(fileName) { if (Object.hasOwn(files, fileName)) return files[fileName]; } }; }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/bundleCode.ts const bundleCode = async ({ files, entryFileName }) => { const bundle = await window.rollup.rollup({ input: entryFileName, plugins: [ pluginResolveModules(files), pluginBabelTransform(), pluginBabelTransformImportsExports() ], external: (source) => { const isResolvable = isRelativeImport(source) || files[source]; return !isResolvable; } }); const { output } = await bundle.generate({ generatedCode: "es2015" }); const bundledCode = output[0].code; return bundledCode; }; //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/compiler/getFnFromString.ts function getFnFromString(fnCode) { /** * Export is transformed by babel to always be `exports.default`. * * We will plug in `exportsStub` object into the function * as the second argument named 'exports'. * Then we will call the function, and get the exported componentFn * assigned to its `exportsStub.default` property. * */ const exportsStub = {}; const [OBJECT_NAME, ASSIGN_TO_PROP] = EXPORTS_OBJ.split("."); const fnArgNames = [GET_IMPORT_FN, OBJECT_NAME]; const func = new Function(...fnArgNames, fnCode); /** * After this call: * - `getImport` would resolve external module imports * - `exportsStub` would be `{ default: componentFn }` * */ func(getImport, exportsStub); const componentFn = exportsStub[ASSIGN_TO_PROP]; return componentFn; } //#endregion //#region src/web/ui/preview/LiveDemoCodeRunner/LiveDemoCodeRunner.tsx const DEBOUNCE_TIME = 800; const LiveDemoCodeRunner = ({ files, error, setError, entryFileName }) => { const [prevCode, setPrevCode] = useState(""); const [dynamicComponent, setDynamicComponent] = useState(null); const getComponent = async (files$1) => { if (!(window.Babel || window.rollup)) return; try { const start = performance.now(); const code = await bundleCode({ entryFileName, files: files$1 }); const end = performance.now(); const diff = Math.round(end - start); console.info(`%cBundled in ${diff}ms`, "background: #15889f; padding: 6px; color: white;"); if (code === prevCode && !error) return; const component = getFnFromString(code); if (typeof component === "function") { setError(void 0); setPrevCode(code); setDynamicComponent(createElement(component)); } else throw new Error(`Couldn't determine a function export in ${entryFileName}.\n\nThe code needs to export a function.`); } catch (e) { console.error(e); setError(e); } }; const getComponentDebounced = useDebouncedCallback(getComponent, DEBOUNCE_TIME); useEffect(() => { getComponentDebounced(files); }, [getComponentDebounced, files]); return dynamicComponent; }; //#endregion //#region src/web/ui/preview/LiveDemoPreview/LiveDemoPreview.module.css?css_module const classes = { "wrapper": "lsciJG_wrapper", "error": "lsciJG_error" }; var LiveDemoPreview_module_default = classes; const _wrapper0 = classes["wrapper"]; const _error0 = classes["error"]; //#endregion //#region src/web/ui/preview/LiveDemoPreview/LiveDemoPreview.tsx const LiveDemoPreview = () => { const { files, entryFileName } = useLiveDemoContext(); const [error, setError] = useState(); const errorOverlay = error ? /* @__PURE__ */ jsx("pre", { className: LiveDemoPreview_module_default.error, children: error?.message }) : null; return /* @__PURE__ */ jsxs("div", { className: LiveDemoPreview_module_default.wrapper, children: [/* @__PURE__ */ jsx(ErrorBoundary, { onError: setError, resetKeys: [files], fallback: errorOverlay, children: /* @__PURE__ */ jsx(LiveDemoCodeRunner, { files, entryFileName, error, setError }) }), errorOverlay] }); }; //#endregion export { Button, LiveDemoCodeRunner, LiveDemoControlPanel, LiveDemoCore, LiveDemoEditor, LiveDemoFileTabs, LiveDemoPreview, LiveDemoProvider, LiveDemoResizablePanels, LiveDemoWrapper, useActiveCode, useLiveDemoContext };