diff --git a/app/web/config/webpack.config.js b/app/web/config/webpack.config.js index 5f8af79..103151b 100644 --- a/app/web/config/webpack.config.js +++ b/app/web/config/webpack.config.js @@ -5,5 +5,9 @@ module.exports = (config, { env }) => { plugin.userOptions.favicon = './src/favicon.svg' } }) + config.module.rules.push({ + test: /\.(md|jscad\.js|py|scad)$/i, + use: 'raw-loader', + }); return config } diff --git a/app/web/package.json b/app/web/package.json index d3f435e..1dd0060 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -58,6 +58,7 @@ "postcss": "^8.3.6", "postcss-import": "^14.0.2", "postcss-loader": "^6.1.1", + "raw-loader": "^4.0.2", "tailwindcss": "^2.2.7" } } diff --git a/app/web/src/components/CadPackage/CadPackage.tsx b/app/web/src/components/CadPackage/CadPackage.tsx index fa3aa82..76ddb3c 100644 --- a/app/web/src/components/CadPackage/CadPackage.tsx +++ b/app/web/src/components/CadPackage/CadPackage.tsx @@ -1,43 +1,75 @@ -export type CadPackageType = 'openscad' | 'cadquery' | 'jscad' +export type CadPackageType = 'openscad' | 'cadquery' | 'jscad' | 'INIT' -export const ideTypeNameMap: { [key in CadPackageType]: string } = { - openscad: 'OpenSCAD', - cadquery: 'CadQuery', - jscad: 'JSCAD', +interface CadPackageConfig { + label: string + buttonClasses: string + dotClasses: string } +export const cadPackageConfigs: { [key in CadPackageType]: CadPackageConfig } = + { + openscad: { + label: 'OpenSCAD', + buttonClasses: 'bg-yellow-800', + dotClasses: 'bg-yellow-200', + }, + cadquery: { + label: 'CadQuery', + buttonClasses: 'bg-ch-blue-700', + dotClasses: 'bg-blue-800', + }, + jscad: { + label: 'JSCAD', + buttonClasses: 'bg-ch-purple-500', + dotClasses: 'bg-yellow-300', + }, + INIT: { + label: '', + buttonClasses: '', + dotClasses: '', + }, + } + interface CadPackageProps { cadPackage: CadPackageType className?: string dotClass?: string + onClick?: any } const CadPackage = ({ cadPackage, className = '', dotClass = 'w-5 h-5', + onClick, }: CadPackageProps) => { - const cadName = ideTypeNameMap[cadPackage] || '' - const isOpenScad = cadPackage === 'openscad' - const isCadQuery = cadPackage === 'cadquery' - const isJsCad = cadPackage === 'jscad' + const cadPackageConfig = cadPackageConfigs[cadPackage] + return ( -
-
{cadName}
-
+ {cadPackageConfig?.label} + + ) +} + +// Returns a proper button if an onClick handler is passed in, or a div +// if the element is meant to be a simple badge +function ButtonOrDiv({ onClick, className, children }) { + return onClick ? ( + + ) : ( +
{children}
) } diff --git a/app/web/src/components/EditorGuide/EditorGuide.tsx b/app/web/src/components/EditorGuide/EditorGuide.tsx new file mode 100644 index 0000000..2b43395 --- /dev/null +++ b/app/web/src/components/EditorGuide/EditorGuide.tsx @@ -0,0 +1,53 @@ +import { useMarkdownMetaData } from 'src/helpers/hooks/useMarkdownMetaData' +import Editor from 'rich-markdown-editor' +import { useRef } from 'react' +import KeyValue from 'src/components/KeyValue/KeyValue' + +export default function EditorGuide({ content }) { + const [rawMetadata, metadata] = useMarkdownMetaData(content) + + const processedContent = rawMetadata + ? content.replace(rawMetadata[0], '') + : content + const ref = useRef(null) + + return ( +
+ {metadata && ( + <> +

{metadata.title}

+
+ {Object.entries(metadata) + .filter(([key]) => key !== 'title') + .map(([key, value], i) => ( + + {value} + + ))} +
+ + )} + {}} + /> +
+ ) +} + +function LinkOrParagraph({ children }) { + const markdownUrlExpression = + /\[(.*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)/i + const matches = children.match(markdownUrlExpression) + + return matches === null ? ( +

{children}

+ ) : ( + + {matches[1]} + + ) +} diff --git a/app/web/src/components/EditorMenu/EditorMenu.tsx b/app/web/src/components/EditorMenu/EditorMenu.tsx index 9ef9456..33c0517 100644 --- a/app/web/src/components/EditorMenu/EditorMenu.tsx +++ b/app/web/src/components/EditorMenu/EditorMenu.tsx @@ -48,7 +48,24 @@ const EditorMenu = () => {
- + { + thunkDispatch({ + type: 'addEditorModel', + payload: { + type: 'guide', + label: 'Guide', + content: state.ideGuide, + }, + }) + thunkDispatch({ + type: 'switchEditorModel', + payload: state.models.length, + }) + }} + /> diff --git a/app/web/src/components/EncodedUrl/ExternalScript.tsx b/app/web/src/components/EncodedUrl/ExternalScript.tsx index 99687dd..db152a4 100644 --- a/app/web/src/components/EncodedUrl/ExternalScript.tsx +++ b/app/web/src/components/EncodedUrl/ExternalScript.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useIdeContext } from 'src/helpers/hooks/useIdeContext' -import { ideTypeNameMap } from 'src/components/CadPackage/CadPackage' +import { cadPackageConfigs } from 'src/components/CadPackage/CadPackage' import OutBound from 'src/components/OutBound/OutBound' import { prepareEncodedUrl, makeExternalUrl } from './helpers' import { copyTextToClipboard } from 'src/helpers/clipboard' @@ -16,7 +16,7 @@ const ExternalScript = () => { 'INIT' | 'SUCCESS' | 'ERROR' | 'LOADING' >('INIT') - const cadName = ideTypeNameMap[state.ideType] + const cadName = cadPackageConfigs[state.ideType].label const onPaste: React.ClipboardEventHandler = async ({ clipboardData, diff --git a/app/web/src/components/IdeEditor/IdeEditor.tsx b/app/web/src/components/IdeEditor/IdeEditor.tsx index 97b9060..7840d4c 100644 --- a/app/web/src/components/IdeEditor/IdeEditor.tsx +++ b/app/web/src/components/IdeEditor/IdeEditor.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import { makeCodeStoreKey, requestRender } from 'src/helpers/hooks/useIdeState' import Editor, { useMonaco } from '@monaco-editor/react' import { theme } from 'src/../config/tailwind.config' import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode' import type { CadPackageType } from 'src/components/CadPackage/CadPackage' +import EditorGuide from 'src/components/EditorGuide/EditorGuide' const colors = theme.extend.colors @@ -17,6 +18,7 @@ const IdeEditor = ({ Loading }) => { cadquery: 'python', openscad: 'cpp', jscad: 'javascript', + INIT: '', } const monaco = useMonaco() useEffect(() => { @@ -73,16 +75,64 @@ const IdeEditor = ({ Loading }) => { className="h-full" onKeyDown={handleSaveHotkey} > - + {state.models.length > 1 && ( +
+ {state.models.map((model, i) => ( + + ))} +
+ )} + {state.models[state.currentModel].type === 'code' ? ( + + ) : ( +
+ +
+ )} ) } diff --git a/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx b/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx index 07959ec..e77725c 100644 --- a/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx +++ b/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx @@ -8,6 +8,7 @@ export const QUERY = gql` id title mainImage + cadPackage createdAt updatedAt user { diff --git a/app/web/src/globals.d.ts b/app/web/src/globals.d.ts new file mode 100644 index 0000000..e35b1e2 --- /dev/null +++ b/app/web/src/globals.d.ts @@ -0,0 +1,7 @@ +// While the raw-loader Webpack plugin actually makes these imports work, this +// eliminates noisy TypeScript errors by registering these file endings as types. +// Learned this method of registering modules from https://stackoverflow.com/a/57444766 +declare module '*.md' +declare module '*.scad' +declare module '*.py' +declare module '*.jscad.js' diff --git a/app/web/src/helpers/cadPackages/cadQuery/initialCode.py b/app/web/src/helpers/cadPackages/cadQuery/initialCode.py new file mode 100644 index 0000000..bfa5c23 --- /dev/null +++ b/app/web/src/helpers/cadPackages/cadQuery/initialCode.py @@ -0,0 +1,17 @@ +# demo shaft coupler + +# ^ first comment is used for download title (i.e. "demo-shaft-coupler.stl") + +# CadQuery docs: https://cadquery.readthedocs.io/ + +import cadquery as cq +from cadquery import exporters + +diam = 5.0 + +result = (cq.Workplane().circle(diam).extrude(20.0) + .faces(">Z").workplane(invert=True).circle(1.05).cutBlind(8.0) + .faces(" @@ -13,77 +14,6 @@ function withThunk(dispatch, getState) { ? actionOrThunk(dispatch, getState) : dispatch(actionOrThunk) } -import { CadPackageType } from 'src/components/CadPackage/CadPackage' - -const initCodeMap: { [key in CadPackageType]: string } = { - openscad: `// involute donut - -// ^ first comment is used for download title (i.e "involute-donut.stl") - -// Follow the OpenSCAD tutorial: https://learn.cadhub.xyz/docs/ - -radius=3; -color(c="DarkGoldenrod")rotate_extrude()translate([20,0])circle(d=30); -color(c="hotpink")rotate_extrude()translate([20,0])offset(radius)offset(-radius)difference(){ - circle(d=34); - translate([-200,-500])square([500,500]); -}`, - cadquery: `# demo shaft coupler - -# ^ first comment is used for download title (i.e. "demo-shaft-coupler.stl") - -# CadQuery docs: https://cadquery.readthedocs.io/ - -import cadquery as cq -from cadquery import exporters - -diam = 5.0 - -result = (cq.Workplane().circle(diam).extrude(20.0) - .faces(">Z").workplane(invert=True).circle(1.05).cutBlind(8.0) - .faces(" `${codeStorageKey}-${ideType}` @@ -95,10 +25,19 @@ interface XYZ { z: number } +interface EditorModel { + type: 'code' | 'guide' + label: string + content?: string +} + export interface State { ideType: 'INIT' | CadPackageType + ideGuide?: string consoleMessages: { type: 'message' | 'error'; message: string; time: Date }[] code: string + models: EditorModel[] + currentModel: number objectData: { type: 'INIT' | ArtifactTypes data: any @@ -136,6 +75,8 @@ export const initialState: State = { { type: 'message', message: 'Initialising', time: new Date() }, ], code, + models: [{ type: 'code', label: 'Code' }], + currentModel: 0, objectData: { type: 'INIT', data: null, @@ -161,6 +102,7 @@ const reducer = (state: State, { type, payload }): State => { initCodeMap[payload.cadPackage] || '', ideType: payload.cadPackage, + ideGuide: initGuideMap[payload.cadPackage], } case 'updateCode': return { ...state, code: payload } @@ -269,6 +211,44 @@ const reducer = (state: State, { type, payload }): State => { ...state, sideTray: payload, } + case 'switchEditorModel': + return { + ...state, + currentModel: payload, + } + case 'addEditorModel': + return { + ...state, + models: [...state.models, payload], + } + case 'removeEditorModel': + return { + ...state, + models: [ + ...state.models.slice(0, payload), + ...state.models.slice(payload + 1), + ], + currentModel: payload === 0 ? 0 : payload - 1, + } + // case 'updateEditorModel': { + // const newModels = [...state.models] + // newModels[state.currentModel].content = payload + // return { + // ...state, + // models: newModels, + // } + // } + // case 'reorderEditorModels': { + // const newModels = [ + // ...state.models.slice(0, state.currentModel), + // ...state.models.slice(state.currentModel + 1), + // ].splice(payload, 0, state.models[state.currentModel]) + // return { + // ...state, + // models: newModels, + // currentModel: payload, + // } + // } default: return state } diff --git a/app/web/src/helpers/hooks/useMarkdownMetaData.ts b/app/web/src/helpers/hooks/useMarkdownMetaData.ts new file mode 100644 index 0000000..cb9ea3e --- /dev/null +++ b/app/web/src/helpers/hooks/useMarkdownMetaData.ts @@ -0,0 +1,27 @@ +// Extracts YAML frontmatter from Markdown files +// Gotten from this helpful comment on a react-markdown GitHub Issue: https://github.com/remarkjs/react-markdown/issues/164#issuecomment-890497653 +export function useMarkdownMetaData(text: string): Array { + const metaData = {} as any + return React.useMemo(() => { + const metaRegExp = RegExp( + /^---[\r\n](((?!---).|[\r\n])*)[\r\n]---$/m + ) as any + // get metadata + const rawMetaData = metaRegExp.exec(text) + + let keyValues + + if (rawMetaData !== null) { + // rawMeta[1] are the stuff between "---" + keyValues = rawMetaData[1].split('\n') + + // which returns a list of key values: ["key1: value", "key2: value"] + keyValues.forEach((keyValue) => { + // split each keyValue to keys and values + const [, key, value] = keyValue.split(/(.+): (.+)/) + metaData[key] = value.trim() + }) + } + return [rawMetaData, metaData] + }, [text]) +} diff --git a/app/web/src/index.css b/app/web/src/index.css index 31e4b74..a0cd4de 100644 --- a/app/web/src/index.css +++ b/app/web/src/index.css @@ -48,19 +48,24 @@ .markdown-overrides { @apply bg-transparent; } +.markdown-overrides, .markdown-overrides div { @apply text-ch-gray-300 bg-transparent; } +.markdown-overrides a, .markdown-overrides div a { @apply text-ch-pink-500 underline bg-transparent; } +.markdown-overrides h3, .markdown-overrides div h3 { @apply text-xl; } +.markdown-overrides h2, .markdown-overrides div h2 { @apply text-2xl; } +.markdown-overrides h1, .markdown-overrides div h1 { @apply text-3xl; } diff --git a/app/yarn.lock b/app/yarn.lock index 20e5d23..02984a2 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -15425,7 +15425,7 @@ raw-body@2.4.0: raw-loader@^4.0.2: version "4.0.2" - resolved "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA== dependencies: loader-utils "^2.0.0"