├── .eslintignore ├── .eslintrc.js ├── packages ├── fiddle │ ├── .gitignore │ ├── src │ │ ├── react-app-env.d.ts │ │ └── index.tsx │ ├── public │ │ └── index.html │ ├── tsconfig.json │ ├── config-overrides.js │ └── package.json └── build │ ├── src │ ├── index.ts │ └── components │ │ └── VisualProgramming.tsx │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── package.json │ └── package-lock.json ├── .travis.yml ├── .gitignore ├── lerna.json ├── tsconfig.build.json ├── tsconfig.json ├── README.md ├── package.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /packages/fiddle/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /packages/build/src/index.ts: -------------------------------------------------------------------------------- 1 | // Hello world -------------------------------------------------------------------------------- /packages/build/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/fiddle/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | 5 | script: 6 | - npm test 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /nbproject/ 2 | /.idea/* 3 | *.tmlanguage.cache 4 | *.tmPreferences.cache 5 | *.stTheme.cache 6 | *.sublime-workspace 7 | *.sublime-project 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*", 4 | "examples/*" 5 | ], 6 | "version": "independent", 7 | "useWorkspaces": true 8 | } 9 | -------------------------------------------------------------------------------- /packages/fiddle/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | ReactDOM.render( 5 | Hi, 6 | document.getElementById("root") 7 | ); 8 | -------------------------------------------------------------------------------- /packages/build/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "./dist" 6 | }, 7 | 8 | "include": [ 9 | "src/**/*" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "sourceMap": true, 6 | "noEmitOnError": true 7 | }, 8 | 9 | "exclude": [ 10 | "node_modules", 11 | "dist" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@build./*": ["packages/*/src"] 8 | }, 9 | "jsx": "react", 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "types": [] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | ## Status 6 | 7 | We have put this experiment on pause for now, to prioritize a simpler/easier interaction system in Builder. But this is still an area we would love to explore deeper in the future 8 | -------------------------------------------------------------------------------- /packages/build/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build./build", 3 | "version": "0.0.1", 4 | "main": "dist/index", 5 | "types": "dist/index", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "npm run clean && npm run compile", 11 | "clean": "rimraf -rf ./dist", 12 | "compile": "tsc -p tsconfig.build.json", 13 | "prepublishOnly": "npm run build", 14 | "test": "npm run build" 15 | }, 16 | "devDependencies": { 17 | "rimraf": "~3.0.2", 18 | "typescript": "~4.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build./repo", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "examples/*" 7 | ], 8 | "scripts": { 9 | "docs": "doctoc --title '**Table of content**' README.md", 10 | "clean": "lerna run clean", 11 | "build": "lerna run build", 12 | "pub": "lerna publish", 13 | "test": "lerna run test" 14 | }, 15 | "devDependencies": { 16 | "doctoc": "~1.4.0", 17 | "eslint": "~7.12.0", 18 | "lerna": "~3.22.0", 19 | "typescript": "~4.0.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/fiddle/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | React App 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/fiddle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/fiddle/config-overrides.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 3 | 4 | module.exports = (config) => { 5 | // Remove the ModuleScopePlugin which throws when we try to import something 6 | // outside of src/. 7 | config.resolve.plugins.pop(); 8 | 9 | // Resolve the path aliases. 10 | config.resolve.plugins.push(new TsconfigPathsPlugin()); 11 | 12 | // Let Babel compile outside of src/. 13 | const tsRule = config.module.rules[2].oneOf[1]; 14 | tsRule.include = undefined; 15 | tsRule.exclude = /node_modules/; 16 | 17 | return config; 18 | }; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Builder.io, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/fiddle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build./fiddle", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@material-ui/core": "^4.11.1", 6 | "@material-ui/icons": "^4.9.1", 7 | "@material-ui/lab": "^4.0.0-alpha.56", 8 | "dedent": "^0.7.0", 9 | "lodash": "^4.17.20", 10 | "mobx-react": "^7.0.5", 11 | "mobx-state-tree": "^4.0.2", 12 | "react": "~16.13.1", 13 | "react-dom": "~16.13.1", 14 | "react-monaco-editor": "^0.40.0", 15 | "traverse": "^0.6.6" 16 | }, 17 | "devDependencies": { 18 | "@types/dedent": "^0.7.0", 19 | "@types/lodash": "^4.14.165", 20 | "@types/node": "~14.0.23", 21 | "@types/react": "~16.9.43", 22 | "@types/react-dom": "~16.9.8", 23 | "@types/react-monaco-editor": "^0.16.0", 24 | "@types/traverse": "^0.6.32", 25 | "cross-env": "~7.0.2", 26 | "react-app-rewired": "~2.1.6", 27 | "react-scripts": "~3.4.1", 28 | "tsconfig-paths-webpack-plugin": "~3.2.0" 29 | }, 30 | "scripts": { 31 | "start": "cross-env SKIP_PREFLIGHT_CHECK=true react-app-rewired start", 32 | "build": "cross-env SKIP_PREFLIGHT_CHECK=true react-app-rewired build", 33 | "test": "yarn run build" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/build/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build./build", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "balanced-match": { 8 | "version": "1.0.0", 9 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 10 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 11 | "dev": true 12 | }, 13 | "brace-expansion": { 14 | "version": "1.1.11", 15 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 16 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 17 | "dev": true, 18 | "requires": { 19 | "balanced-match": "^1.0.0", 20 | "concat-map": "0.0.1" 21 | } 22 | }, 23 | "concat-map": { 24 | "version": "0.0.1", 25 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 26 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 27 | "dev": true 28 | }, 29 | "fs.realpath": { 30 | "version": "1.0.0", 31 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 32 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 33 | "dev": true 34 | }, 35 | "glob": { 36 | "version": "7.1.6", 37 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", 38 | "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", 39 | "dev": true, 40 | "requires": { 41 | "fs.realpath": "^1.0.0", 42 | "inflight": "^1.0.4", 43 | "inherits": "2", 44 | "minimatch": "^3.0.4", 45 | "once": "^1.3.0", 46 | "path-is-absolute": "^1.0.0" 47 | } 48 | }, 49 | "inflight": { 50 | "version": "1.0.6", 51 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 52 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 53 | "dev": true, 54 | "requires": { 55 | "once": "^1.3.0", 56 | "wrappy": "1" 57 | } 58 | }, 59 | "inherits": { 60 | "version": "2.0.4", 61 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 62 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 63 | "dev": true 64 | }, 65 | "minimatch": { 66 | "version": "3.0.4", 67 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 68 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 69 | "dev": true, 70 | "requires": { 71 | "brace-expansion": "^1.1.7" 72 | } 73 | }, 74 | "once": { 75 | "version": "1.4.0", 76 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 77 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 78 | "dev": true, 79 | "requires": { 80 | "wrappy": "1" 81 | } 82 | }, 83 | "path-is-absolute": { 84 | "version": "1.0.1", 85 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 86 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 87 | "dev": true 88 | }, 89 | "rimraf": { 90 | "version": "3.0.2", 91 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 92 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 93 | "dev": true, 94 | "requires": { 95 | "glob": "^7.1.3" 96 | } 97 | }, 98 | "typescript": { 99 | "version": "4.0.5", 100 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz", 101 | "integrity": "sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==", 102 | "dev": true 103 | }, 104 | "wrappy": { 105 | "version": "1.0.2", 106 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 107 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 108 | "dev": true 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/build/src/components/VisualProgramming.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useEffect, useRef } from 'react'; 2 | import * as ts from 'typescript'; 3 | import { useLocalStore, useObserver } from 'mobx-react'; 4 | import MonacoEditor from 'react-monaco-editor'; 5 | import * as dedent from 'dedent'; 6 | import { useReaction } from 'hooks/use-reaction'; 7 | import { useEventListener } from 'hooks/use-event-listener'; 8 | import { safeLsSet, safeLsGet } from 'models/ls-sync'; 9 | import { Portal } from 'react-portal'; 10 | import { Transition } from 'react-transition-group'; 11 | import { theme } from 'constants/theme.constant'; 12 | import ContentEditable from 'react-contenteditable'; 13 | import { pull, camelCase, debounce } from 'lodash'; 14 | import traverse from 'traverse'; 15 | import styled from '@emotion/styled'; 16 | import { Switch, Tabs, Tab, Tooltip, IconButton, TextField, MenuItem } from '@material-ui/core'; 17 | import { humanCase } from 'functions/human-case.function'; 18 | import { appState } from 'constants/app-state.constant'; 19 | import { ShoppingCart, Code, BubbleChart } from '@material-ui/icons'; 20 | 21 | // TODO: add default lib to the language services below too 22 | monaco.languages.typescript.typescriptDefaults.addExtraLib(` 23 | declare var state = any; 24 | declare var context = any; 25 | `); 26 | 27 | // TODO: get dynamically from typeChecker or languageService 28 | const stateProperties = ['active', 'text']; 29 | 30 | export const FILE_NAME = 'code.tsx'; 31 | 32 | export function getProgramForText(text: string) { 33 | const dummyFilePath = FILE_NAME; 34 | const textAst = ts.createSourceFile(dummyFilePath, text, ts.ScriptTarget.Latest); 35 | const options: ts.CompilerOptions = {}; 36 | const host: ts.CompilerHost = { 37 | fileExists: filePath => filePath === dummyFilePath, 38 | directoryExists: dirPath => dirPath === '/', 39 | getCurrentDirectory: () => '/', 40 | getDirectories: () => [], 41 | getCanonicalFileName: fileName => fileName, 42 | getNewLine: () => '\n', 43 | getDefaultLibFileName: () => '', 44 | getSourceFile: filePath => (filePath === dummyFilePath ? textAst : undefined), 45 | readFile: filePath => (filePath === dummyFilePath ? text : undefined), 46 | useCaseSensitiveFileNames: () => true, 47 | writeFile: () => {}, 48 | }; 49 | const languageHost: ts.LanguageServiceHost = { 50 | getScriptFileNames: () => [FILE_NAME], 51 | // What is this? 52 | getScriptVersion: fileName => '3', 53 | getCurrentDirectory: () => '/', 54 | getCompilationSettings: () => options, 55 | getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), 56 | fileExists: filePath => filePath === dummyFilePath, 57 | readFile: filePath => (filePath === dummyFilePath ? text : undefined), 58 | getScriptSnapshot: filePath => 59 | filePath === dummyFilePath ? ts.ScriptSnapshot.fromString(text) : undefined, 60 | }; 61 | const program = ts.createProgram({ 62 | host, 63 | options, 64 | rootNames: [dummyFilePath], 65 | }); 66 | 67 | // TODO: reaction for this on code change 68 | const checker = program.getTypeChecker(); 69 | 70 | const languageService = ts.createLanguageService(languageHost); 71 | 72 | return { 73 | checker, 74 | languageService, 75 | program, 76 | }; 77 | } 78 | 79 | type LanguageExtension = (node: ts.Node, options: Options) => void | JSX.Element; 80 | 81 | export function SetStateExtension(props: AstEditorProps) { 82 | const { node, options } = props; 83 | const propertyName = (node.left as ts.PropertyAccessExpression).name as ts.Identifier; 84 | const value = node.right; 85 | 86 | const fadedBlue = 'rgb(112, 141, 154)'; 87 | 88 | const state = useLocalStore(() => ({ 89 | // Get all state properties via typescripts type checker 90 | getPropertyNames() { 91 | return options.programState.program 92 | .getTypeChecker() 93 | .getTypeAtLocation((node.left as ts.PropertyAccessExpression).expression) 94 | .getApparentProperties() 95 | .map(item => item.name); 96 | }, 97 | getCompletionItems() { 98 | return options.programState.languageService.getCompletionsAtPosition( 99 | FILE_NAME, 100 | (node.left as ts.PropertyAccessExpression).name.getStart(), 101 | {} 102 | ); 103 | }, 104 | })); 105 | 106 | return useObserver(() => { 107 | return ( 108 | 109 | 114 | Set a state property.{' '} 115 | Learn about state 116 | 117 | } 118 | > 119 | 120 | 121 | 122 | Set state 123 | 124 | 125 | 126 | 129 | Choose or create a name for your state property.{' '} 130 | Learn more 131 | 132 | } 133 | > 134 | 135 | 136 | 137 | 138 | 139 | To 140 | 141 | 142 | 143 | ); 144 | }); 145 | } 146 | 147 | const SPACER_TOKEN = '__SPACER__'; 148 | 149 | const createSpacer = () => ts.createIdentifier(SPACER_TOKEN); 150 | 151 | const normalizeExpression = (expression: string) => expression.replace(/\s+/g, ''); 152 | 153 | export function LiquidBubble(props: AstEditorProps) { 154 | const { node, options } = props; 155 | const state = useLocalStore(() => ({ 156 | hovering: false, 157 | showCode: false, 158 | })); 159 | 160 | const liquidEditorRef = useRef(null); 161 | 162 | useEffect(() => { 163 | if (state.showCode && liquidEditorRef.current) { 164 | setTimeout(() => { 165 | liquidEditorRef.current?.focus(); 166 | }, 500); 167 | } 168 | }, [state.showCode]); 169 | 170 | return useObserver(() => { 171 | const liquidExpression = node.arguments[0] as ts.StringLiteral; 172 | const simpleExpression = humanCase(liquidExpression.text.split('|')[0]); 173 | return ( 174 | (state.hovering = true)} 176 | onMouseLeave={() => (state.hovering = false)} 177 | > 178 | 179 | 180 | 181 | {simpleExpression} 182 | 189 | { 205 | (node.arguments as any)[0] = ts.createStringLiteral(stripHtml(e.target.value)); 206 | options.programState.updateCode(); 207 | }} 208 | /> 209 | 210 | 211 |
218 | 221 | Toggle liquid code. Learn more{' '} 222 | 223 | } 224 | > 225 | { 227 | e.stopPropagation(); 228 | state.showCode = !state.showCode; 229 | }} 230 | css={{ padding: 2 }} 231 | > 232 | 233 | 234 | 235 |
236 |
237 |
238 | ); 239 | }); 240 | } 241 | 242 | export function EventListener(props: AstEditorProps) { 243 | const state = useLocalStore(() => ({})); 244 | const { options, node } = props; 245 | 246 | const eventNode = node.arguments[0] as ts.StringLiteral; 247 | const callback = node.arguments[1] as ts.ArrowFunction; 248 | 249 | return useObserver(() => { 250 | return ( 251 | 252 | 253 | 254 | On page 255 | { 273 | (node.arguments as any)[0] = ts.createStringLiteral(stripHtml(e.target.value)); 274 | options.programState.updateCode(); 275 | }} 276 | > 277 | {['scroll', 'click', 'mousedown', 'keypress'].map(item => ( 278 | 279 | {humanCase(item).toLowerCase()} 280 | 281 | ))} 282 | 283 | 284 | 285 | {callback.body && } 286 | 287 | ); 288 | }); 289 | } 290 | 291 | const languageExtensions: LanguageExtension[] = [ 292 | // Liquid bubbles 293 | (node, options) => { 294 | if ( 295 | ts.isCallExpression(node) && 296 | normalizeExpression(node.getText().split('(')[0]) === 'context.shopify.liquid.get' && 297 | node.arguments.length && 298 | ts.isStringLiteral(node.arguments[0]) 299 | ) { 300 | return ; 301 | } 302 | }, 303 | // Liquid bubbles 304 | (node, options) => { 305 | if ( 306 | ts.isPropertyAccessExpression(node) && 307 | normalizeExpression(node.getText()) === 'document.body.scrollTop' 308 | ) { 309 | return ( 310 | 311 | Page scroll position 312 | 313 | ); 314 | } 315 | }, 316 | // Liquid bubbles 317 | (node, options) => { 318 | if ( 319 | ts.isCallExpression(node) && 320 | normalizeExpression(node.getText().split('(')[0]) === 'document.addEventListener' 321 | ) { 322 | return ; 323 | } 324 | }, 325 | // Render spacers 326 | (node, options) => { 327 | if (ts.isIdentifier(node) && node.text === SPACER_TOKEN) { 328 | // TODO: make component and listen for mouseup and delete all spacers 329 | return ( 330 |
342 | ); 343 | } 344 | }, 345 | 346 | // `state.foo = 'bar' to "set state" 347 | (node, options) => { 348 | if (ts.isBinaryExpression(node) && node.operatorToken.getText() === '=') { 349 | if (ts.isPropertyAccessExpression(node.left)) { 350 | if ( 351 | ts.isIdentifier(node.left.expression) && 352 | node.left.expression.text === 'state' && 353 | ts.isIdentifier(node.left.name) 354 | ) { 355 | return ; 356 | } 357 | } 358 | } 359 | }, 360 | ]; 361 | 362 | const replace = (arr: T[], newArr: T[]) => { 363 | arr.length = 0; 364 | arr.push(...newArr); 365 | }; 366 | 367 | const Row = styled.div({ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }); 368 | const Stack = styled.div({ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }); 369 | 370 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 371 | 372 | const findKey = (obj: { [key: string]: any }, value: object) => { 373 | for (const key in obj) { 374 | if (obj[key] === value) { 375 | return key; 376 | } 377 | } 378 | }; 379 | 380 | const replaceNode = (oldNode: ts.Node, newNode: ts.Node) => { 381 | const key = findKey(oldNode.parent, oldNode); 382 | if (key) { 383 | (oldNode.parent as any)[key] = newNode; 384 | } else { 385 | console.error('Could not find key to replace node', { oldNode, newNode }); 386 | } 387 | }; 388 | 389 | type VisualProgrammingProps = { 390 | className?: string; 391 | }; 392 | 393 | export type ProgramState = { 394 | draggingNode: ts.Node | null; 395 | hoveringNode: ts.Node | null; 396 | ast: ts.SourceFile; 397 | selection: ts.Node[]; 398 | updateCode: () => void; 399 | hoveringCodeEditor: boolean; 400 | program: ts.Program; 401 | languageService: ts.LanguageService; 402 | }; 403 | 404 | type Options = { 405 | programState: ProgramState; 406 | }; 407 | 408 | interface AstEditorProps { 409 | options: Options; 410 | node: NodeType; 411 | } 412 | 413 | const stripHtml = (html: string) => { 414 | const div = document.createElement('div'); 415 | div.innerHTML = html; 416 | return div.innerText; 417 | }; 418 | 419 | const localStorageCodeKey = 'builder.experiments.visualProgramming.code'; 420 | 421 | export function VariableStatement(props: AstEditorProps) { 422 | const { node, options } = props; 423 | return useObserver(() => { 424 | return ( 425 | <> 426 | {node.declarationList.declarations.map((item, index) => ( 427 | 428 | ))} 429 | 430 | ); 431 | }); 432 | } 433 | 434 | export function CallExpression(props: AstEditorProps) { 435 | const { node, options } = props; 436 | return useObserver(() => { 437 | return ( 438 | 439 | 440 | Do action 441 | 442 | {node.expression && } 443 | {node.arguments && 444 | node.arguments.map((arg, index) => { 445 | const isFirst = index === 0; 446 | return ( 447 | 448 | 449 | {isFirst ? 'With' : ','} 450 | 451 | 452 | 453 | ); 454 | })} 455 | 456 | ); 457 | }); 458 | } 459 | 460 | export function Identifier( 461 | props: AstEditorProps & { open?: 'left' | 'right' | 'both'; color?: string } 462 | ) { 463 | const { node, options } = props; 464 | return useObserver(() => { 465 | const isActive = Boolean( 466 | options.programState.selection.find(item => ts.isIdentifier(item) && item.text === node.text) 467 | ); 468 | 469 | return ( 470 | replace(options.programState.selection, [node])} 473 | onBlur={() => { 474 | if (options.programState.selection.includes(node)) { 475 | pull(options.programState.selection, node); 476 | } 477 | }} 478 | open={props.open} 479 | active={isActive} 480 | color={props.color || theme.colors.primary} 481 | onChange={text => { 482 | const file = node.getSourceFile(); 483 | const newNode = ts.createIdentifier(text); 484 | 485 | // Update all references to this identifier 486 | // TODO: use ts.transform or ts.visitEachChild or another 487 | // built-in API after figuring out which one is actually right for this 488 | traverse(file).forEach((child: any) => { 489 | if ( 490 | child && 491 | ts.isIdentifier(child) && 492 | !( 493 | ts.isPropertyAssignment(child.parent) || ts.isPropertyAccessExpression(child.parent) 494 | ) && 495 | child.text === node.text 496 | ) { 497 | // Identifiers seem to be immutable in TS AST 498 | replaceNode(child, newNode); 499 | } 500 | }); 501 | options.programState.updateCode(); 502 | }} 503 | > 504 | {node.text} 505 | 506 | ); 507 | }); 508 | } 509 | 510 | const bubbleHeight = 30; 511 | 512 | export function Bubble( 513 | props: PropsWithChildren<{ 514 | options: Options; 515 | color?: string; 516 | active?: boolean; 517 | className?: string; 518 | onFocus?: (event: React.FocusEvent) => void; 519 | onBlur?: (event: React.FocusEvent) => void; 520 | onChange?: (text: string) => void; 521 | open?: 'right' | 'left' | 'both' | 'none'; 522 | humanCase?: boolean; 523 | htmlMode?: boolean; 524 | }> 525 | ) { 526 | const size = bubbleHeight; 527 | 528 | const spacerStyles: Partial = { 529 | backgroundColor: '#222', 530 | width: size + 3, 531 | height: size + 4, 532 | borderRadius: 100, 533 | }; 534 | 535 | const openLeft = props.open === 'left' || props.open === 'both'; 536 | const openRight = props.open === 'right' || props.open === 'both'; 537 | 538 | const gap = 3; 539 | const htmlMode = props.htmlMode === true || props.onChange; 540 | 541 | return useObserver(() => ( 542 |
568 | {openLeft && ( 569 |
576 | )} 577 | {!htmlMode ? ( 578 | props.children 579 | ) : ( 580 | { 591 | props.onChange?.(camelCase(stripHtml(e.target.value))); 592 | props.options.programState.updateCode(); 593 | }} 594 | /> 595 | )} 596 | {openRight && ( 597 |
604 | )} 605 |
606 | )); 607 | } 608 | 609 | export function VariableDeclaration(props: AstEditorProps) { 610 | const { node, options } = props; 611 | return useObserver(() => { 612 | return ( 613 | 614 | 615 | Set 616 | 617 | 618 | 619 | To 620 | 621 | {node.initializer && } 622 | 623 | ); 624 | }); 625 | } 626 | 627 | export function Block(props: AstEditorProps) { 628 | const { node, options } = props; 629 | const tabSpace = 40; 630 | return useObserver(() => { 631 | return ( 632 | 640 |
651 | {node.statements.map((item, index) => ( 652 | 653 | ))} 654 | 655 | ); 656 | }); 657 | } 658 | 659 | export function ExpressionStatement(props: AstEditorProps) { 660 | const { node, options } = props; 661 | return useObserver(() => { 662 | return <>{node.expression && }; 663 | }); 664 | } 665 | 666 | export function ReturnStatement(props: AstEditorProps) { 667 | const { node, options } = props; 668 | return useObserver(() => { 669 | return ( 670 | 671 | 672 | Respond 673 | 674 | {node.expression && } 675 | 676 | ); 677 | }); 678 | } 679 | 680 | export function FunctionDeclaration(props: AstEditorProps) { 681 | const { node, options } = props; 682 | 683 | const color = 'rgb(226, 158, 56)'; 684 | 685 | return useObserver(() => { 686 | return ( 687 | 688 | 689 | 690 | Create action named 691 | 692 | {node.name && } 693 | 694 | that does 695 | 696 | 697 | {node.body && } 698 | 699 | ); 700 | }); 701 | } 702 | export function ArrowFunction(props: AstEditorProps) { 703 | const { node, options } = props; 704 | 705 | const color = 'rgb(226, 158, 56)'; 706 | 707 | return useObserver(() => { 708 | return ( 709 | 710 | 711 | 712 | Do 713 | 714 | 715 | {node.body && } 716 | 717 | ); 718 | }); 719 | } 720 | 721 | export function IfStatement(props: AstEditorProps) { 722 | const { node, options } = props; 723 | 724 | const thenHasIf = node.elseStatement && ts.isIfStatement(node.elseStatement); 725 | 726 | return useObserver(() => { 727 | return ( 728 | <> 729 | 730 | 731 | If 732 | 733 | 734 | 735 | 736 | {thenHasIf ? ( 737 | 738 | 739 | Otherwise 740 | 741 | 742 | 743 | ) : ( 744 | node.elseStatement && ( 745 | <> 746 | Otherwise 747 | 748 | 749 | ) 750 | )} 751 | 752 | ); 753 | }); 754 | } 755 | 756 | export function SourceFile(props: AstEditorProps) { 757 | const { node, options } = props; 758 | return useObserver(() => { 759 | return ( 760 | 761 | {node.statements.map((item, index) => ( 762 | 763 | ))} 764 | 765 | ); 766 | }); 767 | } 768 | export function BinaryExpression(props: AstEditorProps) { 769 | const { node, options } = props; 770 | 771 | const tokenText = node.operatorToken.getText(); 772 | 773 | const isEquals = tokenText === '='; 774 | 775 | const textMap: { [key: string]: string | undefined } = { 776 | '===': 'is', 777 | '==': 'is', 778 | '&&': 'and', 779 | '||': 'or', 780 | '!==': 'is not', 781 | '!=': 'is not', 782 | }; 783 | 784 | const useText = textMap[tokenText] || tokenText; 785 | 786 | return useObserver(() => { 787 | return ( 788 | 789 | {isEquals && ( 790 | 791 | Set 792 | 793 | )} 794 | 795 | 796 | {useText} 797 | 798 | 799 | 800 | ); 801 | }); 802 | } 803 | 804 | const booleanBubbleStyles: Partial = { 805 | height: bubbleHeight, 806 | display: 'flex', 807 | alignItems: 'center', 808 | borderRadius: bubbleHeight, 809 | backgroundColor: '#555', 810 | position: 'relative', 811 | marginLeft: 3, 812 | zIndex: 2, 813 | }; 814 | 815 | export function TrueKeyword(props: AstEditorProps) { 816 | const { node, options } = props; 817 | return useObserver(() => { 818 | return ( 819 |
820 | 821 | { 825 | replaceNode(node, ts.createFalse()); 826 | options.programState.updateCode(); 827 | }} 828 | /> 829 | 830 |
831 | ); 832 | }); 833 | } 834 | export function FalseKeyword(props: AstEditorProps) { 835 | const { node, options } = props; 836 | return useObserver(() => { 837 | return ( 838 |
839 | 840 | { 843 | replaceNode(node, ts.createTrue()); 844 | options.programState.updateCode(); 845 | }} 846 | /> 847 | 848 |
849 | ); 850 | }); 851 | } 852 | 853 | /** 854 | * This is things like `!foo` or `!state.foo` or `+foo` 855 | */ 856 | export function PrefixUnaryExpression(props: AstEditorProps) { 857 | const { node, options } = props; 858 | 859 | return useObserver(() => { 860 | return ( 861 | 862 | {/* 863 | TODO: handle other operators than "!", e.g. `-foo` of `-10` 864 | Rarer ones to add eventually are also things like `+foo` and `~foo` etc 865 | */} 866 | 867 | Not 868 | 869 | 870 | 871 | ); 872 | }); 873 | } 874 | export function PropertyAccessExpression(props: AstEditorProps) { 875 | const { node, options } = props; 876 | return useObserver(() => { 877 | return ( 878 | 879 | 880 | 886 | 887 | ); 888 | }); 889 | } 890 | 891 | // TODO: if works for comments rename to NodeWrapper or something 892 | export function Hoverable(props: PropsWithChildren<{ node: ts.Node; options: Options }>) { 893 | const { options, node } = props; 894 | const state = useLocalStore(() => ({ 895 | onMouseEnter() { 896 | options.programState.hoveringNode = node; 897 | if (options.programState.draggingNode) { 898 | // TODO: find first AST element parent that is an array, make that the subject, splice in 899 | // the spacer 900 | } 901 | }, 902 | onMouseLeave() { 903 | if (options.programState.hoveringNode === node) { 904 | options.programState.hoveringNode = null; 905 | // TODO: remove all spacers from AST 906 | } 907 | }, 908 | })); 909 | 910 | // TODO: turn back on when updated to handle below tasks 911 | const renderComments = false as boolean; 912 | 913 | const comments = 914 | renderComments && 915 | ts.getLeadingCommentRanges(options.programState.ast.getFullText(), node.getFullStart()); 916 | 917 | return ( 918 | <> 919 | {/* 920 | TODO: special handling for jsdoc style 921 | TODO: handle same comment matching multiple times 922 | TODO: make editable 923 | */} 924 | {renderComments && 925 | comments && 926 | comments.map((item, index) => ( 927 | 932 | {options.programState.ast.getFullText().slice(item.pos, item.end)} 933 | 934 | ))} 935 | { 938 | // if (!ts.isLiteralExpression(node)) { 939 | // options.programState.draggingNode = node; 940 | // } 941 | // }} 942 | onMouseEnter={state.onMouseEnter} 943 | onMouseLeave={state.onMouseLeave} 944 | > 945 | {props.children} 946 | 947 | 948 | ); 949 | } 950 | 951 | const hoverable = ( 952 | node: ts.Node, 953 | options: Options, 954 | children: JSX.Element | (() => JSX.Element) 955 | ) => ( 956 | 957 | {typeof children === 'function' ? children() : children} 958 | 959 | ); 960 | 961 | export function Node(props: AstEditorProps) { 962 | const { node, options } = props; 963 | return useObserver(() => 964 | hoverable(node, options, () => { 965 | for (const extension of languageExtensions) { 966 | const result = extension(node, options); 967 | if (result) { 968 | return result; 969 | } 970 | } 971 | if (ts.isVariableStatement(node)) { 972 | return ; 973 | } 974 | if (ts.isVariableDeclaration(node)) { 975 | return ; 976 | } 977 | if (ts.isSourceFile(node)) { 978 | return ; 979 | } 980 | if (ts.isIdentifier(node)) { 981 | return ; 982 | } 983 | if (ts.isPropertyAccessExpression(node)) { 984 | return ; 985 | } 986 | if (ts.isBinaryExpression(node)) { 987 | return ; 988 | } 989 | if (ts.isFunctionDeclaration(node)) { 990 | return ; 991 | } 992 | if (ts.isArrowFunction(node)) { 993 | return ; 994 | } 995 | if (ts.isPrefixUnaryExpression(node)) { 996 | return ; 997 | } 998 | if (ts.isBlock(node)) { 999 | return ; 1000 | } 1001 | if (ts.isExpressionStatement(node)) { 1002 | return ; 1003 | } 1004 | if (ts.isCallExpression(node)) { 1005 | return ; 1006 | } 1007 | if (ts.isReturnStatement(node)) { 1008 | return ; 1009 | } 1010 | if (ts.isIfStatement(node)) { 1011 | return ; 1012 | } 1013 | 1014 | // Can't seem to find a `ts.is*` method for these like the above 1015 | if (node.kind === 106) { 1016 | return ; 1017 | } 1018 | if (node.kind === 91) { 1019 | return ; 1020 | } 1021 | 1022 | if (ts.isStringLiteral(node)) { 1023 | return ( 1024 | { 1028 | node.text = text; 1029 | }} 1030 | > 1031 | {node.text} 1032 | 1033 | ); 1034 | } 1035 | if (ts.isNumericLiteral(node)) { 1036 | return ( 1037 | { 1041 | node.text = text; 1042 | }} 1043 | > 1044 | {node.text} 1045 | 1046 | ); 1047 | } 1048 | 1049 | return ( 1050 | 1051 | 1052 | 1055 | appState.globalState.openDialog( 1056 |
1068 | { 1088 | replaceNode(node, parseCode(val)); 1089 | options.programState.updateCode(); 1090 | }} 1091 | /> 1092 | {/* null} 1096 | /> */} 1097 |
1098 | ) 1099 | } 1100 | > 1101 | 1108 |
1109 |
1110 |
1111 | ); 1112 | }) 1113 | ); 1114 | } 1115 | 1116 | export const createSourceFile = (code: string) => { 1117 | return ts.createSourceFile(FILE_NAME, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); 1118 | }; 1119 | 1120 | // TODO: support multiple statements 1121 | const parseCode = (code: string) => { 1122 | const file = createSourceFile(code); 1123 | return file.statements[0]; 1124 | }; 1125 | 1126 | const defaultTemplates = [ 1127 | [`state.active = true`, 'state'], 1128 | [`state.text = 'Hello!'`, 'state'], 1129 | // TODO: make shopify dynamic, for instance if there is a product-like name in current scope 1130 | [`context.shopify.liquid.get('product.price | currency')`, 'shopify'], 1131 | [`context.shopify.liquid.get('product.name')`, 'shopify'], 1132 | [`context.shopify.liquid.get('product.description')`, 'shopify'], 1133 | [ 1134 | dedent`document.addEventListener('scroll', event => { 1135 | if (document.body.scrollTop > 10) { 1136 | state.scrolledDown = true 1137 | } 1138 | })`, 1139 | 'logic', 1140 | ], 1141 | [ 1142 | dedent`if (state.active) { 1143 | state.active = false; 1144 | }`, 1145 | 'logic', 1146 | ], 1147 | [ 1148 | dedent`function toggleState() { 1149 | state.active = !state.active; 1150 | }`, 1151 | 'logic', 1152 | ], 1153 | ].map(item => { 1154 | const [code, ...tags] = item; 1155 | return { 1156 | tags, 1157 | ast: parseCode(code), 1158 | }; 1159 | }); 1160 | 1161 | function Draggable(props: { node: ts.Node; options: Options }) { 1162 | const { node, options } = props; 1163 | return useObserver(() => ( 1164 | { 1166 | e.preventDefault(); 1167 | options.programState.draggingNode = node; 1168 | }} 1169 | css={{ 1170 | opacity: 0.85, 1171 | marginBottom: 5, 1172 | cursor: 'pointer', 1173 | '&:hover': { 1174 | opacity: 1, 1175 | }, 1176 | '& *': { 1177 | pointerEvents: 'none', 1178 | }, 1179 | }} 1180 | > 1181 | 1182 | 1183 | )); 1184 | } 1185 | 1186 | function DraggingNodeOverlay(props: { options: Options }) { 1187 | const { options } = props; 1188 | return useObserver(() => { 1189 | const node = options.programState.draggingNode; 1190 | return ( 1191 | node && ( 1192 |
1206 | 1207 |
1208 | ) 1209 | ); 1210 | }); 1211 | } 1212 | 1213 | function Toolbox(props: { options: Options; className?: string }) { 1214 | const { options } = props; 1215 | const templates = defaultTemplates; 1216 | 1217 | const TAB_KEY = 'builder.experiments.visualCodingTab'; 1218 | 1219 | const state = useLocalStore(() => ({ 1220 | tab: safeLsGet(TAB_KEY) ?? 0, 1221 | })); 1222 | 1223 | useReaction( 1224 | () => state.tab, 1225 | tab => safeLsSet(TAB_KEY, tab) 1226 | ); 1227 | 1228 | const tabStyle: Partial = { 1229 | minWidth: 0, 1230 | minHeight: 0, 1231 | maxWidth: 'none', 1232 | height: 39, 1233 | color: '#888', 1234 | }; 1235 | 1236 | return useObserver(() => { 1237 | return ( 1238 | 1244 | (state.tab = value)} 1248 | indicatorColor="primary" 1249 | textColor="primary" 1250 | variant="fullWidth" 1251 | > 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | {templates 1259 | .filter(item => { 1260 | switch (state.tab) { 1261 | case 0: 1262 | return true; 1263 | case 1: 1264 | return item.tags.includes('state'); 1265 | case 2: 1266 | return item.tags.includes('shopify'); 1267 | case 3: 1268 | return item.tags.includes('logic'); 1269 | case 4: 1270 | return false; 1271 | } 1272 | }) 1273 | .map((item, index) => ( 1274 | 1275 | ))} 1276 | 1277 | ); 1278 | }); 1279 | } 1280 | 1281 | export function VisualProgramming(props: VisualProgrammingProps) { 1282 | const state = useLocalStore(() => { 1283 | const initialCode = safeLsGet(localStorageCodeKey) || ''; 1284 | return { 1285 | programState: { 1286 | hoveringCodeEditor: false, 1287 | draggingNode: null, 1288 | program: null as any, 1289 | hoveringNode: null, 1290 | updateCode() { 1291 | state.updateCode(); 1292 | }, 1293 | get ast(): ts.SourceFile { 1294 | return state.ast; 1295 | }, 1296 | get selection(): ts.Node[] { 1297 | return state.selection; 1298 | }, 1299 | set selection(arr) { 1300 | replace(state.selection, arr); 1301 | }, 1302 | } as ProgramState, 1303 | selection: [] as ts.Node[], 1304 | code: initialCode, 1305 | ast: createSourceFile(initialCode), 1306 | codeToAst(this: { code: string }, code = this.code) { 1307 | return createSourceFile(code); 1308 | }, 1309 | astToCode(this: { ast: ts.SourceFile | null }, ast = this.ast) { 1310 | return !ast ? '' : printer.printFile(ast); 1311 | }, 1312 | updateAst() { 1313 | this.ast = this.codeToAst(this.code); 1314 | }, 1315 | updateCode() { 1316 | this.code = this.astToCode(this.ast as ts.SourceFile); 1317 | }, 1318 | }; 1319 | }); 1320 | 1321 | useReaction( 1322 | () => state.code, 1323 | code => safeLsSet(localStorageCodeKey, code) 1324 | ); 1325 | 1326 | useReaction( 1327 | () => state.code, 1328 | () => { 1329 | state.updateAst(); 1330 | 1331 | const tsInfo = getProgramForText(state.code); 1332 | state.programState.program = tsInfo.program; 1333 | state.programState.languageService = tsInfo.languageService; 1334 | } 1335 | ); 1336 | 1337 | useEventListener(document, 'mouseup', () => { 1338 | if (state.programState.draggingNode) { 1339 | if (state.programState.hoveringCodeEditor) { 1340 | const node = state.programState.draggingNode; 1341 | traverse(node).forEach(function (child) { 1342 | if (child && ts.isStringLiteral(child)) { 1343 | this.update(ts.createStringLiteral(child.text)); 1344 | } 1345 | if (child && ts.isNumericLiteral(child)) { 1346 | this.update(ts.createNumericLiteral(child.text)); 1347 | } 1348 | }); 1349 | // TODO: modify AST here 1350 | state.code += '\n' + printer.printNode(ts.EmitHint.Unspecified, node, state.ast); 1351 | } 1352 | 1353 | state.programState.draggingNode = null; 1354 | } 1355 | }); 1356 | 1357 | useEventListener(document, 'keydown', e => { 1358 | const event = e as KeyboardEvent; 1359 | // Esc key 1360 | if (event.which === 27 && state.programState.draggingNode) { 1361 | state.programState.draggingNode = null; 1362 | } 1363 | }); 1364 | 1365 | return useObserver(() => { 1366 | const options = { programState: state.programState }; 1367 | return ( 1368 |
1377 | 1378 | 1379 | {transitionState => ( 1380 |
1395 | )} 1396 | 1397 | 1398 |
1404 |
1405 | 1406 |
1407 |
1408 |
1411 |
{ 1419 | state.programState.hoveringCodeEditor = true; 1420 | }} 1421 | onMouseLeave={() => { 1422 | state.programState.hoveringCodeEditor = false; 1423 | }} 1424 | > 1425 | {state.ast && } 1426 | {state.programState.draggingNode && 1427 | !state.programState.hoveringNode && 1428 | state.programState.hoveringCodeEditor && ( 1429 |
1437 | )} 1438 | 1439 |
1440 |
1448 | { 1464 | state.code = val; 1465 | }} 1466 | /> 1467 |
1468 |
1469 |
1470 | ); 1471 | }); 1472 | } 1473 | --------------------------------------------------------------------------------