diff --git a/package.json b/package.json index 3e00c8f..dc0a5e2 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,19 @@ "preview": "vite preview" }, "dependencies": { - "@uiw/codemirror-extensions-color": "^4.23.0", + "@codemirror/autocomplete": "^6.17.0", + "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.1", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.28.4", "@uiw/codemirror-extensions-langs": "^4.23.0", "@uiw/codemirror-theme-vscode": "^4.23.0", "@uiw/react-codemirror": "^4.23.0", "@uiw/react-json-view": "1.12.0", "color-selector-react": "0.3.0-beta.4", + "colors-named": "^1.0.2", + "colors-named-hex": "^1.0.2", + "hsl-matcher": "^1.2.4", "localforage": "^1.10.0", "match-sorter": "^6.3.4", "react": "^18.2.0", @@ -30,8 +37,6 @@ "valtio-history": "^0.3.5" }, "devDependencies": { - "@codemirror/autocomplete": "^6.17.0", - "@codemirror/state": "^6.4.1", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-transition-group": "^4.4.10", diff --git a/src/components/Live/LiveProvider.tsx b/src/components/Live/LiveProvider.tsx index 7c31c53..ab0e2be 100644 --- a/src/components/Live/LiveProvider.tsx +++ b/src/components/Live/LiveProvider.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, ComponentType, PropsWithChildren } from "react"; import LiveContext from "./LiveContext"; -import { generateElement, renderElementAsync } from "../../utils/transpile"; +import { renderElementAsync } from "../../utils/transpile"; import { themes } from "prism-react-renderer"; type ProviderState = { @@ -13,7 +13,6 @@ type Props = { disabled?: boolean; enableTypeScript?: boolean; language?: string; - noInline?: boolean; scope?: Record; theme?: typeof themes.nightOwl; transformCode?(code: string): void; @@ -28,7 +27,6 @@ function LiveProvider({ disabled = false, scope, transformCode, - noInline = true, }: PropsWithChildren) { const [state, setState] = useState({ error: undefined, @@ -58,12 +56,8 @@ function LiveProvider({ enableTypeScript, }; - if (noInline) { - setState({ error: undefined, element: null }); // Reset output for async (no inline) evaluation - renderElementAsync(input, renderElement, errorCallback); - } else { - renderElement(generateElement(input, errorCallback)); - } + setState({ error: undefined, element: null }); // Reset output for async (no inline) evaluation + renderElementAsync(input, renderElement, errorCallback); } catch (error) { return errorCallback(error as Error); } @@ -77,7 +71,7 @@ function LiveProvider({ useEffect(() => { transpileAsync(code).catch(onError); - }, [code, scope, noInline, transformCode]); + }, [code, scope, transformCode]); const onChange = (newCode: string) => { transpileAsync(newCode).catch(onError); diff --git a/src/data/colors.ts b/src/data/colors.ts index 5975d96..91b5408 100644 --- a/src/data/colors.ts +++ b/src/data/colors.ts @@ -3,40 +3,40 @@ import { ColorSchema } from "../../typings"; export const themeColorSchema: ColorSchema = { onLight: { "primary-7": "rgb(14, 66, 210)", - "primary-6": "rgb(22, 93, 255)", - "primary-5": "rgb(64, 128, 255)", - "primary-4": "rgb(106, 161, 255)", - "primary-3": "rgb(148, 191, 255)", - "primary-2": "rgb(190, 218, 255)", - "primary-1": "rgb(232, 243, 255)", + // "primary-6": "rgb(22, 93, 255)", + // "primary-5": "rgb(64, 128, 255)", + // "primary-4": "rgb(106, 161, 255)", + // "primary-3": "rgb(148, 191, 255)", + // "primary-2": "rgb(190, 218, 255)", + // "primary-1": "rgb(232, 243, 255)", "success-7": "rgb(0, 154, 41)", - "success-6": "rgb(0, 180, 42)", - "success-5": "rgb(35, 195, 67)", - "success-4": "rgb(76, 210, 99)", - "success-3": "rgb(123, 225, 136)", - "success-2": "rgb(175, 240, 181)", - "success-1": "rgb(232, 255, 234)", + // "success-6": "rgb(0, 180, 42)", + // "success-5": "rgb(35, 195, 67)", + // "success-4": "rgb(76, 210, 99)", + // "success-3": "rgb(123, 225, 136)", + // "success-2": "rgb(175, 240, 181)", + // "success-1": "rgb(232, 255, 234)", "warning-7": "rgb(210, 95, 0)", - "warning-6": "rgb(255, 125, 0)", - "warning-5": "rgb(255, 154, 46)", - "warning-4": "rgb(255, 182, 93)", - "warning-3": "rgb(255, 207, 139)", - "warning-2": "rgb(255, 228, 186)", - "warning-1": "rgb(255, 247, 232)", + // "warning-6": "rgb(255, 125, 0)", + // "warning-5": "rgb(255, 154, 46)", + // "warning-4": "rgb(255, 182, 93)", + // "warning-3": "rgb(255, 207, 139)", + // "warning-2": "rgb(255, 228, 186)", + // "warning-1": "rgb(255, 247, 232)", "danger-7": "rgb(203, 39, 45)", - "danger-6": "rgb(245, 63, 63)", - "danger-5": "rgb(247, 101, 96)", - "danger-4": "rgb(249, 137, 129)", - "danger-3": "rgb(251, 172, 163)", - "danger-2": "rgb(253, 205, 197)", - "danger-1": "rgb(255, 236, 232)", + // "danger-6": "rgb(245, 63, 63)", + // "danger-5": "rgb(247, 101, 96)", + // "danger-4": "rgb(249, 137, 129)", + // "danger-3": "rgb(251, 172, 163)", + // "danger-2": "rgb(253, 205, 197)", + // "danger-1": "rgb(255, 236, 232)", "link-7": "rgb(14, 66, 210)", - "link-6": "rgb(22, 93, 255)", - "link-5": "rgb(64, 128, 255)", - "link-4": "rgb(106, 161, 255)", - "link-3": "rgb(148, 191, 255)", - "link-2": "rgb(190, 218, 255)", - "link-1": "rgb(232, 243, 255)", + // "link-6": "rgb(22, 93, 255)", + // "link-5": "rgb(64, 128, 255)", + // "link-4": "rgb(106, 161, 255)", + // "link-3": "rgb(148, 191, 255)", + // "link-2": "rgb(190, 218, 255)", + // "link-1": "rgb(232, 243, 255)", "data-1": "rgb(64, 128, 255)", "data-2": "rgb(190, 218, 255)", "data-3": "rgb(85, 197, 253)", @@ -57,36 +57,36 @@ export const themeColorSchema: ColorSchema = { "data-18": "rgb(137, 233, 224)", "data-19": "rgb(232, 101, 223)", "data-20": "rgb(247, 186, 239)", - "border-1": "rgb(242, 243, 245)", - "border-2": "rgb(229, 230, 235)", - "border-3": "rgb(201, 205, 212)", - "border-4": "rgb(134, 144, 156)", - "border-5": "rgb(78, 89, 105)", - "border-6": "rgb(29, 33, 41)", + // "border-1": "rgb(242, 243, 245)", + // "border-2": "rgb(229, 230, 235)", + // "border-3": "rgb(201, 205, 212)", + // "border-4": "rgb(134, 144, 156)", + // "border-5": "rgb(78, 89, 105)", + // "border-6": "rgb(29, 33, 41)", "border-7": "rgb(14, 16, 20)", - "fill-1": "rgb(247, 248, 250)", - "fill-2": "rgb(242, 243, 245)", - "fill-3": "rgb(229, 230, 235)", - "fill-4": "rgb(201, 205, 212)", - "fill-5": "rgb(134, 144, 156)", - "fill-6": "rgb(78, 89, 105)", - "fill-7": "rgb(29, 33, 41)", - "text-1": "rgb(29, 33, 41)", - "text-2": "rgb(78, 89, 105)", - "text-3": "rgb(134, 144, 156)", - "text-4": "rgb(201, 205, 212)", - "text-5": "rgb(229, 230, 235)", - "text-6": "rgb(242, 243, 245)", - "text-7": "rgb(247, 248, 250)", - "background-1": "rgb(255, 255, 255)", - "background-2": "rgb(255, 255, 255)", - "background-3": "rgb(255, 255, 255)", - "background-4": "rgb(255, 255, 255)", - "background-5": "rgb(255, 255, 255)", - "background-6": "rgb(255, 255, 255)", - "background-7": "rgb(255, 255, 255)", - "background-white": "rgb(255, 255, 255)", - "background-black": "rgb(0, 0, 0)", - "shadow": "rgb(255, 255, 255)", + // "fill-1": "rgb(247, 248, 250)", + // "fill-2": "rgb(242, 243, 245)", + // "fill-3": "rgb(229, 230, 235)", + // "fill-4": "rgb(201, 205, 212)", + // "fill-5": "rgb(134, 144, 156)", + // "fill-6": "rgb(78, 89, 105)", + // "fill-7": "rgb(29, 33, 41)", + // "text-1": "rgb(29, 33, 41)", + // "text-2": "rgb(78, 89, 105)", + // "text-3": "rgb(134, 144, 156)", + // "text-4": "rgb(201, 205, 212)", + // "text-5": "rgb(229, 230, 235)", + // "text-6": "rgb(242, 243, 245)", + // "text-7": "rgb(247, 248, 250)", + // "background-1": "rgb(255, 255, 255)", + // "background-2": "rgb(255, 255, 255)", + // "background-3": "rgb(255, 255, 255)", + // "background-4": "rgb(255, 255, 255)", + // "background-5": "rgb(255, 255, 255)", + // "background-6": "rgb(255, 255, 255)", + // "background-7": "rgb(255, 255, 255)", + // "background-white": "rgb(255, 255, 255)", + // "background-black": "rgb(0, 0, 0)", + // "shadow": "rgb(255, 255, 255)", }, }; diff --git a/src/data/functionBlocks.ts b/src/data/functionBlocks.ts index db01e6f..f040157 100644 --- a/src/data/functionBlocks.ts +++ b/src/data/functionBlocks.ts @@ -1,8 +1,9 @@ -import { CodeBlock } from '../../typings/index'; +import { CodeBlock } from "../../typings/index"; export const functionBlockList: CodeBlock[] = [ { code: ` + const Banner = (props: { title: string; subTitle: string; @@ -27,7 +28,7 @@ const Banner = (props: { ); }; -export default Banner +export default Banner; `, props: { title: "AI智能建站助手", @@ -303,4 +304,15 @@ export default Banner }, cpnName: "Contant", }, + { + code: ` + const Test = () => { + return
Test
; + }; + + export default Test; + `, + props: {}, + cpnName: "Test", + }, ]; diff --git a/src/pages/app/index.tsx b/src/pages/app/index.tsx index c6f355b..165e5d0 100644 --- a/src/pages/app/index.tsx +++ b/src/pages/app/index.tsx @@ -1,15 +1,13 @@ import React, { useState, useEffect, ComponentType } from "react"; import { functionBlockList } from "../../data/functionBlocks"; -import { Transform } from "sucrase"; -import evalCode from "../../utils/transpile/evalCode"; -import transform from "../../utils/transpile/transform"; import { ThemeProvider } from "../../components/Theme/ThemePrivider"; import errorBoundary from "../../utils/transpile/errorBoundary"; import "./style.css"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import minifyCode from "../../utils/minify"; -// import { execToModule } from "../../utils/transpile/moduleImport"; -// import ReactDOM from "react-dom/client"; +import { + transformCodeProd, +} from "../../utils/transpile/code-transformer"; +import evalCode from "../../utils/transpile/evalCode"; export const App = () => { // 定义成一个数组便于下面操作,实际上只有一个元素 @@ -19,8 +17,6 @@ export const App = () => { console.error(error); }; - const transforms: Transform[] = ["jsx", "typescript"]; - const render = (element: ComponentType) => { if (typeof element === "undefined") { errorCallBack(new SyntaxError("`render` must be called with valid JSX.")); @@ -32,13 +28,14 @@ export const App = () => { // 开发环境下,useEffect会执行两次,模拟装载和卸载组件,生产环境没事。 useEffect(() => { - const allCpns = functionBlockList.map((item) => item.code).join("\n").replace(/export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, ""); + const allCpns = functionBlockList + .map((item) => "import " + item.cpnName + " from '" + item.cpnName + "';") + .join("\n"); const allPage = functionBlockList .map((item, index) => `<${item.cpnName} { ...props[${index}] }/>`) .join("\n"); const allCode = ` ${allCpns} - const ApplicationContext = () => { return (
@@ -47,30 +44,30 @@ export const App = () => { ); }; - export default ApplicationContext + export default ApplicationContext; `; - const propsList = functionBlockList.map((item) => item.props); - const allTransformed = transform({ transforms })(allCode); - setTimeout(async () => { - const minifiedCode = await minifyCode(allTransformed); - const codeToRun = minifiedCode.replace(/export\s+default/g, "return") - const ApplicationContext = evalCode( - codeToRun, - { - React, - props: propsList, - } - ); - render(ApplicationContext); - // // console.log(ApplicationContext); - // const module = await execToModule(minifiedCode, { - // React: 'https://unpkg.com/react@17/umd/react.production.min.js', - // props: propsList, - // }) - // render(module.default); + const prod = await transformCodeProd({ + code: allCode, + dependencies: (item) => { + return { + code: functionBlockList.find((i) => i.cpnName === item.identifier) + ?.code, + identifier: item.identifier, + }; + }, + }); + + console.log(`prod: ${prod}`); + + const app = await evalCode(prod, { + React, + props: propsList, + }); + + render(app); }, 1000); }, []); diff --git a/src/pages/creation/components/props-editor.tsx b/src/pages/creation/components/props-editor.tsx new file mode 100644 index 0000000..1f97583 --- /dev/null +++ b/src/pages/creation/components/props-editor.tsx @@ -0,0 +1,74 @@ +import JsonViewEditor from "@uiw/react-json-view/editor"; + +export const PropsEditor = ( + { + newPropKey, + setNewPropKey, + handleAddProp, + componentsProps, + setComponentsProps + }: { + newPropKey: string, + setNewPropKey: (value: string) => void, + handleAddProp: () => void, + componentsProps: Record, + setComponentsProps: (value: Record) => void + } +) => { + return ( +
+

Props 编辑器

+
+
+ +
+ setNewPropKey(e.target.value)} + className="border border-black rounded p-2 flex-grow" + /> + +
+
+ { + const updateNestedProp = ( + obj: Record, + path: string[], + value: unknown + ) => { + if (path.length === 1) { + obj[path[0]] = value; + } else { + if (!obj[path[0]]) { + obj[path[0]] = {}; + } + updateNestedProp(obj[path[0]], path.slice(1), value); + } + }; + const updatedProps = { ...componentsProps }; + updateNestedProp( + updatedProps, + (opts as any).namespace, + opts.value + ); + setComponentsProps(updatedProps); + return true; + }} + /> +
+
+ ) +} \ No newline at end of file diff --git a/src/pages/creation/components/sidebar.tsx b/src/pages/creation/components/sidebar.tsx new file mode 100644 index 0000000..a45c4db --- /dev/null +++ b/src/pages/creation/components/sidebar.tsx @@ -0,0 +1,25 @@ +export const Sidebar = ({ + items, + selectedItem, + onSelectItem, + }: { + items: { cpnName: string; props: Record; code: string }[]; + selectedItem: number; + onSelectItem: (index: number) => void; + }) => ( +
+
    + {items.map((item, index) => ( +
  • onSelectItem(index)} + > + {item.cpnName} +
  • + ))} +
+
+ ); \ No newline at end of file diff --git a/src/pages/creation/extensions/classNameExtension .ts b/src/pages/creation/extensions/classNameExtension .ts new file mode 100644 index 0000000..5687bb7 --- /dev/null +++ b/src/pages/creation/extensions/classNameExtension .ts @@ -0,0 +1,86 @@ +import { + ViewPlugin, + EditorView, + WidgetType, + Decoration, + ViewUpdate, + DecorationSet, +} from "@codemirror/view"; +import { Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; + +// Function to create a decoration for the className attribute +function classNameDecorations(view: EditorView): DecorationSet { + const widgets: Range[] = []; + syntaxTree(view.state).iterate({ + enter: ({ type, from, to }) => { + if (type.name === "JSXAttribute") { + const attributeName = view.state.doc.sliceString(from, to); + + if (attributeName.startsWith("className")) { + const widget = Decoration.widget({ + widget: new ClassNameWidget(), + side: -1, + }); + widgets.push(widget.range(from)); + } + } + }, + }); + return Decoration.set(widgets); +} + +class ClassNameWidget extends WidgetType { + toDOM() { + const wrapper = document.createElement("span"); + const square = document.createElement("div"); + square.style.width = "12px"; + square.style.height = "12px"; + square.style.backgroundColor = "white"; + square.style.display = "inline-block"; + square.style.marginRight = "0.5ch"; + square.style.border = "1px solid #00000040"; + square.style.verticalAlign = "middle"; + wrapper.appendChild(square); + return wrapper; + } + ignoreEvent() { + return false; + } +} + +export const classNameView = () => + ViewPlugin.fromClass( + class ClassNameView { + decorations; + constructor(view: EditorView) { + this.decorations = classNameDecorations(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = classNameDecorations(update.view); + } + } + }, + { + decorations: (v) => v.decorations, + } + ); + +export const classNamePlugin = [classNameView()]; + +export const classNameTheme = EditorView.baseTheme({ + "span[data-classname]": { + width: "12px", + height: "12px", + display: "inline-block", + borderRadius: "2px", + marginRight: "0.5ch", + outline: "1px solid #00000040", + overflow: "hidden", + verticalAlign: "middle", + marginTop: "-2px", + }, +}); + +export const classNameExtension = [classNamePlugin, classNameTheme]; diff --git a/src/pages/creation/extensions/colorExtension.ts b/src/pages/creation/extensions/colorExtension.ts new file mode 100644 index 0000000..7639404 --- /dev/null +++ b/src/pages/creation/extensions/colorExtension.ts @@ -0,0 +1,324 @@ +import { ViewPlugin, EditorView, ViewUpdate, WidgetType, Decoration, DecorationSet } from '@codemirror/view'; +import { Extension, Range } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import colors from 'colors-named'; +import hexs from 'colors-named-hex'; +import hslMatcher, { hlsStringToRGB, RGBAColor } from 'hsl-matcher'; + +export function toFullHex(color: string): string[] { + if (color.length === 4) { + // 3-char hex + return [`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, '']; + } + + if (color.length === 5) { + // 4-char hex (alpha) + return [`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, color[4].repeat(2)]; + } + + if (color.length === 9) { + // 8-char hex (alpha) + return [`#${color.slice(1, -2)}`, color.slice(-2)]; + } + + return [color, '']; +} +/** https://stackoverflow.com/a/5624139/1334703 */ +export function rgbToHex(r: number, g: number, b: number) { + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); +} + +/** https://stackoverflow.com/a/5624139/1334703 */ +export function hexToRgb(hex: string) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** https://css-tricks.com/converting-color-spaces-in-javascript/#aa-rgb-to-hsl */ +export function RGBToHSL(r: number, g: number, b: number) { + (r /= 255), (g /= 255), (b /= 255); + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h = 0, + s, + l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + return { h: Math.floor(h * 360), s: Math.floor(s * 100), l: Math.floor(l * 100) }; +} + +export enum ColorType { + rgb = 'RGB', + hex = 'HEX', + named = 'NAMED', + hsl = 'HSL', +} + +export interface ColorState { + from: number; + to: number; + alpha: string; + colorType: ColorType; +} + +const colorState = new WeakMap(); + +type GetArrayElementType = T extends readonly (infer U)[] ? U : never; + +function colorDecorations(view: EditorView) { + const widgets: Array> = []; + for (const range of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from: range.from, + to: range.to, + enter: ({ type, from, to }) => { + const callExp: string = view.state.doc.sliceString(from, to); + + if (type.name === 'CallExpression' && callExp.startsWith('rgb')) { + const match = + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*(,\s*\d*\.\d*\s*)?\)/i.exec(callExp) || + /rgba?\(\s*(\d{1,3})\s*(\d{1,3})\s*(\d{1,3})\s*(\/?\s*\d+%)?(\/\s*\d+\.\d\s*)?\)/i.exec(callExp); + if (!match) return; + const [_, r, g, b, a] = match; + const hex = rgbToHex(Number(r), Number(g), Number(b)); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.rgb, + color: hex, + colorRaw: callExp, + from, + to, + alpha: a ? a.replace(/(\/|,)/g, '') : '', + }), + side: 0, + }); + widgets.push(widget.range(from)); + } else if (type.name === 'CallExpression' && hslMatcher(callExp)) { + + const match = hlsStringToRGB(callExp) as RGBAColor; + if (!match) return; + const { r, g, b } = match; + const hex = rgbToHex(Number(r), Number(g), Number(b)); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.hsl, + color: hex, + colorRaw: callExp, + from, + to, + alpha: match.a ? match.a.toString() : '', + }), + side: 0, + }); + widgets.push(widget.range(from)); + } else if (type.name === 'ColorLiteral') { + const [color, alpha] = toFullHex(callExp); + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.hex, + color, + colorRaw: callExp, + from, + to, + alpha, + }), + side: 0, + }); + widgets.push(widget.range(from)); + } else if (type.name === 'ValueName') { + const name = callExp as unknown as GetArrayElementType; + if (colors.includes(name)) { + const widget = Decoration.widget({ + widget: new ColorWidget({ + colorType: ColorType.named, + color: hexs[colors.indexOf(name)], + colorRaw: callExp, + from, + to, + alpha: '', + }), + side: 0, + }); + widgets.push(widget.range(from)); + } + } + }, + }); + } + return Decoration.set(widgets); +} + +class ColorWidget extends WidgetType { + private readonly state: ColorState; + private readonly color: string; + private readonly colorRaw: string; + + constructor({ + color, + colorRaw, + ...state + }: ColorState & { + color: string; + colorRaw: string; + }) { + super(); + this.state = state; + this.color = color; + this.colorRaw = colorRaw; + } + eq(other: ColorWidget) { + return ( + other.state.colorType === this.state.colorType && + other.color === this.color && + other.state.from === this.state.from && + other.state.to === this.state.to && + other.state.alpha === this.state.alpha + ); + } + toDOM() { + const picker = document.createElement('input'); + colorState.set(picker, this.state); + picker.type = 'color'; + picker.value = this.color; + picker.dataset['color'] = this.color; + picker.dataset['colorraw'] = this.colorRaw; + const wrapper = document.createElement('span'); + wrapper.appendChild(picker); + wrapper.dataset['color'] = this.color; + wrapper.style.backgroundColor = this.colorRaw; + return wrapper; + } + ignoreEvent() { + return false; + } +} + +export const colorView = (showPicker: boolean = true) => + ViewPlugin.fromClass( + class ColorView { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = colorDecorations(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = colorDecorations(update.view); + } + const readOnly = update.view.contentDOM.ariaReadOnly === 'true'; + const editable = update.view.contentDOM.contentEditable === 'true'; + + const canBeEdited = readOnly === false && editable; + this.changePicker(update.view, canBeEdited); + } + changePicker(view: EditorView, canBeEdited: boolean) { + const doms = view.contentDOM.querySelectorAll('input[type=color]'); + doms.forEach((inp) => { + if (!showPicker) { + inp.setAttribute('disabled', ''); + } else { + canBeEdited ? inp.removeAttribute('disabled') : inp.setAttribute('disabled', ''); + } + }); + } + }, + { + decorations: (v) => v.decorations, + eventHandlers: { + change: (e, view) => { + const target = e.target as HTMLInputElement; + if ( + target.nodeName !== 'INPUT' || + !target.parentElement || + (!target.dataset.color && !target.dataset.colorraw) + ) + return false; + const data = colorState.get(target)!; + const value = target.value; + const rgb = hexToRgb(value); + const colorraw = target.dataset.colorraw; + const slash = (target.dataset.colorraw || '').indexOf('/') > 4; + const comma = (target.dataset.colorraw || '').indexOf(',') > 4; + let converted = target.value; + if (data.colorType === ColorType.rgb) { + let funName = colorraw?.match(/^(rgba?)/) ? colorraw?.match(/^(rgba?)/)![0] : undefined; + if (comma) { + converted = rgb + ? `${funName}(${rgb.r}, ${rgb.g}, ${rgb.b}${data.alpha ? ', ' + data.alpha.trim() : ''})` + : value; + } else if (slash) { + converted = rgb + ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b}${data.alpha ? ' / ' + data.alpha.trim() : ''})` + : value; + } else { + converted = rgb ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b})` : value; + } + } else if (data.colorType === ColorType.hsl) { + const rgb = hexToRgb(value); + if (rgb) { + const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b); + converted = `hsl(${h}deg ${s}% ${l}%${data.alpha ? ' / ' + data.alpha : ''})`; + } + } + view.dispatch({ + changes: { + from: data.from, + to: data.to, + insert: converted, + }, + }); + return true; + }, + }, + }, + ); + +export const colorTheme = EditorView.baseTheme({ + 'span[data-color]': { + width: '12px', + height: '12px', + display: 'inline-block', + borderRadius: '2px', + marginRight: '0.5ch', + outline: '1px solid #00000040', + overflow: 'hidden', + verticalAlign: 'middle', + marginTop: '-2px', + }, + 'span[data-color] input[type="color"]': { + background: 'transparent', + display: 'block', + border: 'none', + outline: '0', + paddingLeft: '24px', + height: '12px', + }, + 'span[data-color] input[type="color"]::-webkit-color-swatch': { + border: 'none', + paddingLeft: '24px', + }, +}); + +export const color: Extension = [colorView(), colorTheme]; \ No newline at end of file diff --git a/src/pages/creation/completions.ts b/src/pages/creation/extensions/completions.ts similarity index 99% rename from src/pages/creation/completions.ts rename to src/pages/creation/extensions/completions.ts index 4b8700b..bdc54d8 100644 --- a/src/pages/creation/completions.ts +++ b/src/pages/creation/extensions/completions.ts @@ -95,4 +95,4 @@ export function colorCompletions(data: Completion[] = []): Extension { }); } -export const plugin = colorCompletions(colorMap); \ No newline at end of file +export default colorCompletions(colorMap); \ No newline at end of file diff --git a/src/pages/creation/extensions/exportLinter.ts b/src/pages/creation/extensions/exportLinter.ts new file mode 100644 index 0000000..320d4a7 --- /dev/null +++ b/src/pages/creation/extensions/exportLinter.ts @@ -0,0 +1,35 @@ +import { syntaxTree } from "@codemirror/language"; +import { linter, Diagnostic } from "@codemirror/lint"; + +const exportLinter = linter((view) => { + let diagnostics: Diagnostic[] = []; + syntaxTree(view.state) + .cursor() + .iterate((node) => { + if (node.name == "ExportDeclaration") { + let code = view.state.doc.sliceString(node.from, node.to); + if (!code.trim().endsWith(";")) { + diagnostics.push({ + from: node.from, + to: node.to, + severity: "error", + message: "Export declaration must end with a semicolon", + actions: [ + { + name: "Add semicolon", + apply(view, _from, to) { + view.dispatch({ + changes: { from: to, to, insert: ";" }, + }); + // 把分号添加到结尾 + }, + }, + ], + }); + } + } + }); + return diagnostics; +}); + +export default exportLinter; diff --git a/src/pages/creation/extensions/index.ts b/src/pages/creation/extensions/index.ts new file mode 100644 index 0000000..b6de9ea --- /dev/null +++ b/src/pages/creation/extensions/index.ts @@ -0,0 +1,2 @@ +export { default as exportLinter } from "./exportLinter"; + diff --git a/src/pages/creation/extensions/jsxIdentifierLinter.ts b/src/pages/creation/extensions/jsxIdentifierLinter.ts new file mode 100644 index 0000000..9b29544 --- /dev/null +++ b/src/pages/creation/extensions/jsxIdentifierLinter.ts @@ -0,0 +1,62 @@ +import { syntaxTree } from "@codemirror/language"; +import { linter, Diagnostic } from "@codemirror/lint"; + +// List of common JSX native identifiers to ignore +const jsxNativeIdentifiers = new Set([ + "div", "span", "p", "a", "ul", "li", "ol", "h1", "h2", "h3", "h4", "h5", "h6", + "input", "button", "form", "label", "textarea", "select", "option", "img", + "className", "id", "style", "key", "ref", "children" +]); + +const jsxIdentifierLinter = linter(view => { + let diagnostics: Diagnostic[] = []; + let imports = new Set(); + let variableNames = new Set(); + + // Traverse the syntax tree to collect all import declarations and variable names + syntaxTree(view.state).cursor().iterate(node => { + if (node.name === "ImportDeclaration") { + let code = view.state.doc.sliceString(node.from, node.to); + let importedNames = code.match(/import\s+([^\s]+)\s+from\s+/); + if (importedNames && importedNames[1]) { + importedNames[1].split(',').forEach(name => imports.add(name.trim())); + } + } + + if (node.name === "VariableDeclaration") { + let variableNode = node.node; + let variableNameNode = variableNode.firstChild?.nextSibling; + if (variableNameNode && variableNameNode.name === "VariableName") { + let variableName = view.state.doc.sliceString(variableNameNode.from, variableNameNode.to); + variableNames.add(variableName); + } + } + }); + + // Traverse the syntax tree to check for JSXIdentifiers and missing imports + syntaxTree(view.state).cursor().iterate(node => { + if (node.name === "JSXIdentifier") { + let identifierName = view.state.doc.sliceString(node.from, node.to); + if (!jsxNativeIdentifiers.has(identifierName) && !imports.has(identifierName) && !variableNames.has(identifierName)) { + diagnostics.push({ + from: node.from, + to: node.to, + severity: "error", + message: `Missing import for ${identifierName}`, + actions: [{ + name: "Add import", + apply(view, _from, _to) { + view.dispatch({ + changes: { from: 0, to: 0, insert: `import ${identifierName} from "${identifierName}";\n` } + }); + } + }] + }); + } + } + }); + + return diagnostics; +}); + +export default jsxIdentifierLinter; diff --git a/src/pages/creation/extensions/variableImportLinter.ts b/src/pages/creation/extensions/variableImportLinter.ts new file mode 100644 index 0000000..ec2be5b --- /dev/null +++ b/src/pages/creation/extensions/variableImportLinter.ts @@ -0,0 +1,31 @@ +import { syntaxTree } from "@codemirror/language"; +import { linter, Diagnostic } from "@codemirror/lint"; + +const variableImportLinter = linter(view => { + let diagnostics: Diagnostic[] = []; + // Traverse the syntax tree to collect all import declarations and global variable names + syntaxTree(view.state).cursor().iterate(node => { + if (node.name == "ImportDeclaration") { + let code = view.state.doc.sliceString(node.from, node.to); + if (!code.trim().endsWith(";")) { + diagnostics.push({ + from: node.from, + to: node.to, + severity: "error", + message: "Import declaration must end with a semicolon", + actions: [{ + name: "Add semicolon", + apply(view, _from, to) { + view.dispatch({ + changes: { from: to, to, insert: ";" } + }); + } + }] + }); + } + } + }); + return diagnostics; +}); + +export default variableImportLinter; diff --git a/src/pages/creation/index.tsx b/src/pages/creation/index.tsx index 32076d6..7822188 100644 --- a/src/pages/creation/index.tsx +++ b/src/pages/creation/index.tsx @@ -4,19 +4,29 @@ import { loadLanguage, langs } from "@uiw/codemirror-extensions-langs"; import LiveProvider from "../../components/Live/LiveProvider"; import LivePreview from "../../components/Live/LivePreview"; import LiveError from "../../components/Live/LiveError"; -import { color, colorView, colorTheme } from "@uiw/codemirror-extensions-color"; -import JsonViewEditor from "@uiw/react-json-view/editor"; import { ThemeProvider } from "../../components/Theme/ThemePrivider"; -import { plugin as colorCom } from "./completions"; import { functionBlockList } from "../../data/functionBlocks"; import { vscodeDark } from "@uiw/codemirror-theme-vscode"; import ColorPalette from "./components/colorPalette"; +import { PropsEditor } from "./components/props-editor"; +import { Sidebar } from "./components/sidebar"; +import { color } from "./extensions/colorExtension"; +import exportLinter from "./extensions/exportLinter"; +import variableImportLinter from "./extensions/variableImportLinter"; +import jsxIdentifierLinter from "./extensions/jsxIdentifierLinter"; +import { classNameExtension } from "./extensions/classNameExtension "; loadLanguage("tsx"); -const extensions = [color, langs.tsx(), colorView(false), colorTheme, colorCom]; +const extensions = [ + langs.tsx(), + color, + exportLinter, + variableImportLinter, + jsxIdentifierLinter, + classNameExtension, +]; export const Creation = () => { - // useEffect 拦截ctrls等操作,避免浏览器默认行为 React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.ctrlKey && event.key === "s") { @@ -30,15 +40,21 @@ export const Creation = () => { }; }, []); + const [selectedBlockIndex, setSelectedBlockIndex] = React.useState(0); const [componentName, setComponentName] = React.useState( functionBlockList[0].cpnName ); - const [componentsProps, setComponentsProps] = React.useState( functionBlockList[0].props ); - const [newPropKey, setNewPropKey] = React.useState(""); + const [code, setCode] = React.useState(functionBlockList[0].code); + + React.useEffect(() => { + setComponentName(functionBlockList[selectedBlockIndex].cpnName); + setComponentsProps(functionBlockList[selectedBlockIndex].props); + setCode(functionBlockList[selectedBlockIndex].code); + }, [selectedBlockIndex]); const handleAddProp = () => { if (newPropKey.trim() !== "") { @@ -50,8 +66,6 @@ export const Creation = () => { } }; - const [code, setCode] = React.useState(functionBlockList[0].code); - const onChange = React.useCallback((value: string) => { setCode(value); }, []); @@ -63,100 +77,68 @@ export const Creation = () => { }; return ( -
-
- - -
- -
-
- -
-
- - - -
+
+ +
+
+ +
- - -
-
-

Props 编辑器

-
-
- -
- setNewPropKey(e.target.value)} - className="border border-black rounded p-2 flex-grow" +
+ +
+
+ - +
+
+ + +
- { - const updateNestedProp = ( - obj: Record, - path: string[], - value: unknown - ) => { - if (path.length === 1) { - obj[path[0]] = value; - } else { - if (!obj[path[0]]) { - obj[path[0]] = {}; - } - updateNestedProp(obj[path[0]], path.slice(1), value); - } - }; - const updatedProps = { ...componentsProps }; - updateNestedProp( - updatedProps, - (opts as any).namespace, - opts.value - ); - setComponentsProps(updatedProps); - return true; - }} - /> -
+ + +
+
+ +
-
); diff --git a/src/pages/editor/index.tsx b/src/pages/editor/index.tsx index 76821ae..45f6d4c 100644 --- a/src/pages/editor/index.tsx +++ b/src/pages/editor/index.tsx @@ -107,7 +107,6 @@ export const Editor = () => { return ( => { + const allTransformed = transform({ transforms })(code); + + // 这里还有import和export default,需要处理一下 + const imports: ImportInfo[] = []; + const regex = /import\s+([^;]+)\s+from\s+['"]([^'"]+)['"];/g; + const matches = allTransformed.matchAll(regex); + + for (const match of matches) { + imports.push({ + identifier: match[1].trim(), + path: match[2].trim(), + }); + } + + // 获取所有的标识符, 防止压缩的时候被替换 + const identifiers = imports.map((item) => item.identifier); + + // 压缩 + const minifiedCode = await minifyCode(allTransformed, identifiers); + + // 替换导入和导出 + const codeToRun = minifiedCode + .replace(/export\s+default/g, "return") + .replace(/import\s+[^;]+;/g, ""); + return [codeToRun, imports]; +}; + +/** + * 将代码转换为React组件 + * @param param0 参数 + * @returns 返回React组件和导入信息 + */ +export const transformCodeToReactComponent = async ({ + code, + functonProps, + transforms = defaultTransforms, +}: TransformerProps): Promise => { + const [codeToRun, imports] = await transformCode(code, transforms); + + return { + element: evalCode(codeToRun, functonProps), + importMap: imports, + }; +}; + +/** + * 递归处理代码和依赖,构建生产环境代码 + * @param param0 参数 + * @returns 返回生产环境代码 + */ +export const transformCodeProd = async ({ + code, + dependencies, + excludeImports = [], +}: TransformCodeProdProps): Promise => { + // 递归处理代码和依赖 + const importedIdentifiers = new Map(); + + const resolveImports = async (code: string): Promise => { + const imports: ImportInfo[] = []; + const regex = /import\s+([^;]+)\s+from\s+['"]([^'"]+)['"];/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(code)) !== null) { + imports.push({ + identifier: match[1].trim(), + path: match[2].trim(), + }); + } + + // 获取所有依赖的代码 + const promises = imports.map(async (imp) => { + // 如果是排除的导入或者已经导入过了,则直接跳过 + if (excludeImports.includes(imp.identifier)) { + return { + identifier: imp.identifier, + code: "", + }; + } + + if (importedIdentifiers.has(imp.identifier)) { + return { + identifier: imp.identifier, + code: importedIdentifiers.get(imp.identifier) || "", + }; + } + + const result = await dependencies(imp); + if (!result || !result.code) { + throw new Error( + `Failed to resolve dependency: ${imp.identifier} in code: \n${code}` + ); + } + + // 递归处理获取的依赖代码 + const resolvedCode = await resolveImports(result.code); + + return { + identifier: imp.identifier, + code: resolvedCode, + }; + }); + + const resolvedDependencies = await Promise.all(promises); + + // 替换导入标识符为实际代码 + resolvedDependencies.forEach(({ identifier, code: depCode }) => { + if (!depCode) return; + + // 如果这个标识符已经处理过,直接替换掉import + if (importedIdentifiers.has(identifier)) { + const importRegex = new RegExp( + `import\\s+${identifier}\\s+from\\s+['"][^'"]+['"];`, + "g" + ); + code = code.replace(importRegex, ""); + return; + } + + const noExport = depCode.replace(/export\s+default\s+[\s\S]*?;/g, ""); + + const importRegex = new RegExp( + `import\\s+${identifier}\\s+from\\s+['"][^'"]+['"];`, + "g" + ); + + const all = code.match(importRegex); + + // 如果大于1个,则在这个代码块中存在问题(重复导入) + if (all!.length > 1) { + throw new Error( + `Duplicate import found for ${identifier} in code: \n${code}` + ); + } + + code = code.replace(importRegex, noExport); + + // 标记为已经处理过 + importedIdentifiers.set(identifier, depCode); + }); + + return code; + }; + + /** + * 处理代码 + */ + return new Promise(async (resolve, reject) => { + // 初始解析代码 + try { + const resolvedCode = await resolveImports(code); + + console.log(`resolvedCode: ${resolvedCode}`); + + const prod = await transformCode(resolvedCode); + resolve(prod[0]); + } catch (error) { + reject(error); + } + }); +}; diff --git a/src/utils/transpile/index.ts b/src/utils/transpile/index.ts index bebf789..f573a86 100644 --- a/src/utils/transpile/index.ts +++ b/src/utils/transpile/index.ts @@ -1,10 +1,7 @@ import React, { ComponentType } from "react"; -import transform from "./transform"; import errorBoundary from "./errorBoundary"; -import evalCode from "./evalCode"; -import compose from "./compose"; import { Transform } from "sucrase"; -import { addJsxConst, spliceJsxConst, trimCode, wrapReturn } from "../handler"; +import { transformCodeToReactComponent } from "./code-transformer"; type GenerateOptions = { code: string; @@ -12,40 +9,7 @@ type GenerateOptions = { enableTypeScript: boolean; }; -export const generateElement = ( - { code = "", scope = {}, enableTypeScript = true }: GenerateOptions, - errorCallback: (error: Error) => void -) => { - /** - * To enable TypeScript we need to transform the TS to JS code first, - * splice off the JSX const, wrap the eval in a return statement, then - * transform any imports. The two-phase approach is required to do - * the implicit evaluation and not wrap leading Interface or Type - * statements in the return. - */ - - const firstPassTransforms: Transform[] = ["jsx"]; - enableTypeScript && firstPassTransforms.push("typescript"); - - // 通过compose函数,将多个函数组合成一个函数,用于Inline编辑器的代码转换 - const transformed = compose( - addJsxConst, - transform({ transforms: ["imports"] }), - spliceJsxConst, - trimCode, - transform({ transforms: firstPassTransforms }), - wrapReturn, - trimCode - )(code); - - return errorBoundary( - // 作用域在这里定义,用到React,所以需要传入React - evalCode(transformed, { React, ...scope }), - errorCallback - ); -}; - -export const renderElementAsync = ( +export const renderElementAsync = async ( { code = "", scope = {}, enableTypeScript = true }: GenerateOptions, resultCallback: (sender: ComponentType) => void, errorCallback: (error: Error) => void @@ -53,7 +17,9 @@ export const renderElementAsync = ( ) => { const render = (element: ComponentType) => { if (typeof element === "undefined") { - errorCallback(new SyntaxError("The Code didn't return a valid JSX element!")); + errorCallback( + new SyntaxError("The Code didn't return a valid JSX element!") + ); } else { resultCallback(errorBoundary(element, errorCallback)); } @@ -68,18 +34,14 @@ export const renderElementAsync = ( const transforms: Transform[] = ["jsx"]; enableTypeScript && transforms.splice(1, 0, "typescript"); - const codeTorun = transform({ transforms })(code).replace( - /export default/g, - "return" - ); - - const Cpn = evalCode(codeTorun, { React, ...scope, render }); - - // if (!React.isValidElement(Cpn)) { - // return errorCallback( - // new SyntaxError("The Code didn't return a valid JSX element!") - // ); - // } - - render(Cpn); + try { + const elementInfo = await transformCodeToReactComponent({ + code, + functonProps: { React, ...scope } as any, + }); + console.log(`importMap: ${JSON.stringify(elementInfo.importMap)}`); + render(elementInfo.element); + } catch (error: any) { + errorCallback(error); + } }; diff --git a/src/utils/transpile/moduleImport.ts b/src/utils/transpile/moduleImport.ts index 1307da0..e056648 100644 --- a/src/utils/transpile/moduleImport.ts +++ b/src/utils/transpile/moduleImport.ts @@ -16,7 +16,6 @@ export const execToModule = async ( // 添加到code之前 const scopedCode = `${importStatements}\n${code}`; - // 使用Babel或其他工具进行转换 const allTransformed = transform({ transforms })(scopedCode); const minifiedCode = await minifyCode(allTransformed); diff --git a/src/utils/transpile/transform.ts b/src/utils/transpile/transform.ts index 9d51a86..34c6719 100644 --- a/src/utils/transpile/transform.ts +++ b/src/utils/transpile/transform.ts @@ -1,6 +1,6 @@ import { transform as _transform, Transform } from "sucrase"; -const defaultTransforms: Transform[] = ["jsx", "imports"]; +const defaultTransforms: Transform[] = ["jsx", "typescript"]; type Options = { transforms?: Transform[]; @@ -13,4 +13,4 @@ export default function transform(opts: Options = {}) { return (code: string) => _transform(code, { transforms, production: true }).code; -} +} \ No newline at end of file diff --git a/typings/index.ts b/typings/index.ts index c3847ef..129c7a2 100644 --- a/typings/index.ts +++ b/typings/index.ts @@ -1,3 +1,6 @@ +import React, { ComponentType } from "react"; +import { Transform } from "sucrase"; + export type CodeBlock = { code: string; cpnName: string; @@ -58,3 +61,40 @@ export type Change = { key: string; type: ChangeType; }; + +export type ImportInfo = { + identifier: string; + path: string; +}; + +export type ReactComponentInfo = { + element: ComponentType; + importMap: ImportInfo[]; +}; + +export type TransformerProps = { + code: string; + functonProps: { + React: typeof React; + props: Record; + [key: string]: any; + }; + transforms?: Transform[]; +}; + +export type ReslovedDependency = { + identifier: string; + code?: string; +}; + +export type DependencyGetter = (info: ImportInfo) => ReslovedDependency | Promise; + +export type TransformCodeProdProps = { + code: string; + /** + * 组件名(标识符) to 组件code + */ + dependencies: DependencyGetter; + transforms?: Transform[]; + excludeImports?: string[]; +}; \ No newline at end of file