@live-demo/core
Version:
Core components for @live-demo plugins.
687 lines (650 loc) • 22.8 kB
JavaScript
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 };