├── .gitignore ├── .prettierrc.json ├── README.md ├── package-lock.json ├── package.json ├── src ├── app │ └── pages │ │ ├── editor │ │ ├── CustomHtmlElements │ │ │ ├── CustomElement.ts │ │ │ ├── Dropdown.ts │ │ │ ├── Grid.ts │ │ │ ├── IconButton.ts │ │ │ ├── Menus │ │ │ │ ├── AddWidgetMenu.ts │ │ │ │ └── Menu.ts │ │ │ ├── OpalBody.ts │ │ │ ├── WidgetInspector.ts │ │ │ └── Widgets │ │ │ │ ├── Layout │ │ │ │ └── ContainerWidget.ts │ │ │ │ ├── Text │ │ │ │ └── TextboxWidget.ts │ │ │ │ └── Widget.ts │ │ ├── icons │ │ │ ├── Alert.svg │ │ │ ├── Cross.svg │ │ │ ├── DropdownArrow.svg │ │ │ ├── Layers.svg │ │ │ ├── Page.svg │ │ │ └── Plus.svg │ │ ├── index.html │ │ ├── index.ts │ │ ├── scripts │ │ │ ├── attatchEventListeners.ts │ │ │ ├── attatchIpcListeners.ts │ │ │ ├── build.ts │ │ │ ├── inspector.ts │ │ │ ├── load.ts │ │ │ ├── save.ts │ │ │ └── sidenav.ts │ │ ├── styles │ │ │ ├── custom-widgets.css │ │ │ ├── index.css │ │ │ ├── inspector.css │ │ │ ├── preview.css │ │ │ ├── sidenav.css │ │ │ └── widgets.css │ │ ├── types.ts │ │ └── util │ │ │ ├── appendCustomHtmlElement.ts │ │ │ ├── camelToCapitalised.ts │ │ │ ├── handleInspectorChange.ts │ │ │ ├── pxToNumber.ts │ │ │ ├── setContentEditableCursorEnd.ts │ │ │ ├── state.ts │ │ │ ├── toCamel.ts │ │ │ └── toDashes.ts │ │ ├── menu │ │ ├── index.html │ │ ├── index.ts │ │ └── style.css │ │ └── new-project │ │ ├── index.html │ │ ├── index.ts │ │ └── style.css ├── electron │ └── src │ │ ├── attachIpcListeners.ts │ │ ├── main.ts │ │ ├── menu.ts │ │ └── preload.ts └── opalApiTemplate.js ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /src/app/build 3 | /src/electron/build 4 | /projects 5 | /dist -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Opal 2 | 3 | ## Description 4 | 5 | Opal is a content management system designed for web developers who wish to simplify their job whilst having a large portion control over web applications. 6 | 7 | ## How it works 8 | 9 | Developers are given the ability to layout the design of a website with our UI, and code any functionality using our JavaScript API, which aims to simplify the currently used document object model. 10 | 11 | When creating a project the developer will be prompted for where they would like to store the data for their project. Here will be stored the configurations for the project, the code written by the developer, and the website code generated by Opal. 12 | 13 | Opal interprets all configurations and scripts and generates optimised website code, ready for deployment. 14 | 15 | ## Build instructions 16 | 17 | - Clone/pull the repository locally 18 | - Enter the main directory in terminal (the directory including the `README.md`) 19 | - `npm i` - install dependencies 20 | - `npm run compile-electron` - compile the electron source code - run this when first installing and when updating the electron source code 21 | - `npm start` - start the application 22 | 23 | ## What makes Opal unique 24 | 25 | ### The common problem 26 | 27 | A common problem in the web development industry is that web development can often be a very tedious task to plough through. Building a website properly has become a very time-consuming task and has many often-considered hurdles that slow a developer down from doing what they love - writing "real" code. I realise that many would disagree and state that HTML and CSS are real programming languages, but many would agree that HTML and CSS are nothing but a pain and a chore when building out websites. 28 | 29 | In my opinion, HTML and CSS have resulted in a quite frankly shitty JavaScript API, the DOM. Due to this, JavaScript frameworks such as React and Vue have been created in an attempt to make this process easier, and implement HTML into JavaScript itself or vice versa. 30 | 31 | Opal completely removes HTML and CSS from the development process, and leaves the developer with just a design tool, and a scripting API. This allows the developer to quickly and easily create beautiful web designs in a matter of minutes, whilst maintaining full control over the functionality and scripting of the website. This also eliminates the need for a design, as the Opal editor substitutes this. 32 | 33 | ### Flexibility & the Opal API 34 | 35 | There are many great CMSs on the market, but as of writing none of these provide the same amount of control and flexibility that you may have writing a website from scratch. Opal provides an easy to use and intuitive JavaScript API, used as a replacement to the old-school DOM. This API eliminates the need to manually find every element before using it, and transforms many tedious tasks into pleasurable ones. 36 | 37 | ### Well thought-out UI 38 | 39 | Opal is designed to give the developer optimal control whilst keeping the UI as simplistic as possible, concealing advanced features for those who need them and minimising intimidation by the editor upon first-time launch. I am not going to sit here and lie to you, there is a steep learning curve to using the editor effectively. This is especially true if you have not written a website from scratch before, as Opal uses a lot of terminology that would be used frequently in the web development industry. Thankfully, Opal provides a free guide to the editor, the API as well as a fully complete documentation on the editor. 40 | 41 | ### Fast websites 42 | 43 | Upon deployment, Opal generates a well optimsied website, generating the minimal website code that is required in order to function as intended. By doing so, websites created with Opal will be just as fast, if not faster than a website created from scratch. 44 | 45 | ### SEO is down to the developer 46 | 47 | Opal provides the option to have great SEO on the developer's website. With the control over page titles, text types and widget names it is the developer's decision in how far they wish to integrate SEO. 48 | 49 | ### Free & open source 50 | 51 | Opal is entirely, 100% free for non commercial use, and is open source. This allows for developers to create great websites at no cost, just as they would with HTML, CSS and JavaScript. 52 | 53 | ## Elements 54 | 55 | ### Description 56 | 57 | Widgets can be used by developers to add pieces of UI to a website. A widget is a component of a website such as a button or pricing list. Each and every widget consists of its own properties, which can be accessed through the inspector or via the API. 58 | 59 | ### Typography 60 | 61 | #### Textbox 62 | 63 | A widget that allows you to add text to your webpage. 64 | 65 | ##### Properties 66 | 67 | **Type** - the type of text that you're adding - this mainly is here for SEO purposes. \ 68 | **Text** - the text displayed by the widget \ 69 | **Size** - size of the text (in pixels) 70 | **Weight** - weight of the text 71 | 72 | ### Layout 73 | 74 | #### Column 75 | 76 | A container that allows widgets to be placed in a vertical order. 77 | 78 | ### Row 79 | 80 | A container that allows widgets to be placed in a horizontal order. 81 | 82 | #### Properties 83 | 84 | ## Future features 85 | 86 | ### Plugins 87 | 88 | There is a possibility that plugins will indeed be supported with Opal, especially as it is open source. Developers will then have the option to create their own plugins and implement their own useful featuers to Opal, being able to share them with the community. 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opal", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "webpack.config.js", 6 | "scripts": { 7 | "start": "webpack && electron ./src/electron/build/main.js", 8 | "start-full": "npm run compile-electron && npm start", 9 | "compile-electron": "tsc ./src/electron/src/main.ts ./src/electron/src/preload.ts --outDir ./src/electron/build" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@types/express": "^4.17.13", 16 | "electron": "^17.0.1", 17 | "html-webpack-plugin": "^5.5.0", 18 | "ts-loader": "^9.2.6", 19 | "typescript": "^4.5.2", 20 | "webpack": "^5.64.2", 21 | "webpack-cli": "^4.9.1" 22 | }, 23 | "dependencies": { 24 | "express": "^4.17.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/CustomElement.ts: -------------------------------------------------------------------------------- 1 | export default class CustomElement { 2 | public htmlElement: HTMLElement; 3 | 4 | constructor(type: string = "div") { 5 | this.htmlElement = document.createElement(type); 6 | } 7 | 8 | public addClass(className: string) { 9 | this.htmlElement.classList.add(className); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Dropdown.ts: -------------------------------------------------------------------------------- 1 | import CustomElement from "./CustomElement"; 2 | 3 | /** 4 | * Automatically inserts to parent as required to in order to function correctly 5 | */ 6 | export default class Dropdown extends CustomElement { 7 | constructor(label: string, parent: HTMLElement, child: HTMLElement, private isDropped: boolean = true) { 8 | super(); 9 | this.htmlElement.className = "dropdown"; 10 | 11 | const arrowElement = document.createElement("img"); 12 | arrowElement.src = "./icons/DropdownArrow.svg"; 13 | arrowElement.style.margin = "0 10px"; 14 | arrowElement.style.transform = "rotate(180deg)"; 15 | 16 | const text = document.createElement("h5"); 17 | text.innerText = label; 18 | 19 | this.htmlElement.appendChild(arrowElement); 20 | this.htmlElement.appendChild(text); 21 | 22 | parent.appendChild(this.htmlElement); 23 | parent.insertBefore(child, this.htmlElement.nextSibling); 24 | 25 | this.htmlElement.addEventListener("click", () => { 26 | if (this.isDropped) { 27 | arrowElement.style.transform = "rotate(0deg)"; 28 | child.remove(); 29 | } else { 30 | arrowElement.style.transform = "rotate(180deg)"; 31 | parent.insertBefore(child, this.htmlElement.nextSibling); 32 | } 33 | 34 | this.isDropped = !this.isDropped; 35 | }) 36 | } 37 | } -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Grid.ts: -------------------------------------------------------------------------------- 1 | import CustomElement from "./CustomElement"; 2 | 3 | export default class Grid extends CustomElement { 4 | constructor() { 5 | super(); 6 | this.htmlElement.className = "grid-container"; 7 | } 8 | 9 | public addItem(element: HTMLElement): void { 10 | element.className = "grid-item"; 11 | this.htmlElement.appendChild(element); 12 | } 13 | } -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/IconButton.ts: -------------------------------------------------------------------------------- 1 | import CustomElement from "./CustomElement"; 2 | 3 | export default class IconButton extends CustomElement { 4 | htmlElement: HTMLImageElement; 5 | 6 | constructor(svgPath: string) { 7 | super("img"); 8 | this.htmlElement.className = "icon-button"; 9 | this.htmlElement.src = svgPath; 10 | } 11 | } -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Menus/AddWidgetMenu.ts: -------------------------------------------------------------------------------- 1 | import Dropdown from "../Dropdown"; 2 | import Grid from "../Grid"; 3 | import Menu from "./Menu"; 4 | import TextboxWidget from "../Widgets/Text/TextboxWidget"; 5 | import Widget from "../Widgets/Widget"; 6 | import ContainerWidget from "../Widgets/Layout/ContainerWidget"; 7 | 8 | export default class AddWidgetMenu extends Menu { 9 | constructor() { 10 | super("Add Widget"); 11 | 12 | const typographyGrid = new Grid(); 13 | this.addWidget("Textbox", TextboxWidget, typographyGrid); 14 | 15 | const layoutGrid = new Grid(); 16 | this.addWidget("Column", ContainerWidget, layoutGrid); 17 | 18 | new Dropdown("Layout", this.htmlElement, layoutGrid.htmlElement); 19 | new Dropdown( 20 | "Typography", 21 | this.htmlElement, 22 | typographyGrid.htmlElement 23 | ); 24 | new Dropdown("Input", this.htmlElement, document.createElement("div")); 25 | } 26 | 27 | private addWidget(label: string, widgetType: typeof Widget, grid: Grid) { 28 | const htmlElement = document.createElement("h5"); 29 | htmlElement.style.cursor = "grab"; 30 | htmlElement.draggable = true; 31 | htmlElement.innerText = label; 32 | 33 | grid.addItem(htmlElement); 34 | htmlElement.addEventListener("dragstart", (e) => { 35 | e.dataTransfer.setData("widget-type", String(widgetType)); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Menus/Menu.ts: -------------------------------------------------------------------------------- 1 | import appendCustomElement from "../../util/appendCustomHtmlElement"; 2 | import CustomElement from "../CustomElement"; 3 | import IconButton from "../IconButton"; 4 | 5 | export default class Menu extends CustomElement { 6 | private titleTextElement: HTMLHeadingElement; 7 | 8 | constructor(titleTxt: string, isHidden = true) { 9 | super(); 10 | this.htmlElement.className = "block"; 11 | if (isHidden) this.htmlElement.style.display = "none"; 12 | this.htmlElement.style.borderLeft = "none"; 13 | 14 | const title = document.createElement("div"); 15 | title.className = "sidenav-item-title"; 16 | 17 | const text = document.createElement("h4"); 18 | text.style.color = "#C5C5C5"; 19 | text.innerText = titleTxt; 20 | text.style.marginLeft = "10px"; 21 | 22 | this.titleTextElement = text; 23 | 24 | const closeButton = new IconButton("./icons/Cross.svg"); 25 | closeButton.htmlElement.style.marginRight = "10px"; 26 | closeButton.htmlElement.addEventListener("click", () => { 27 | this.htmlElement.style.display = "none"; 28 | const closeEvent = new CustomEvent("close"); 29 | this.htmlElement.dispatchEvent(closeEvent); 30 | }) 31 | 32 | title.appendChild(text); 33 | appendCustomElement(title, closeButton); 34 | this.htmlElement.appendChild(title); 35 | } 36 | 37 | public open(): void { 38 | this.htmlElement.style.display = "block"; 39 | } 40 | 41 | public close(): void { 42 | this.htmlElement.style.display = "none"; 43 | } 44 | 45 | public set titleText(text: string) { 46 | this.titleTextElement.innerText = text; 47 | } 48 | } -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/OpalBody.ts: -------------------------------------------------------------------------------- 1 | import { createInspector } from "../scripts/inspector"; 2 | import appendCustomHtmlElement from "../util/appendCustomHtmlElement"; 3 | import { state } from "../util/state"; 4 | import CustomElement from "./CustomElement"; 5 | import TextboxWidget from "./Widgets/Text/TextboxWidget"; 6 | import Widget from "./Widgets/Widget"; 7 | 8 | class OpalBody extends CustomElement { 9 | constructor() { 10 | super("div"); 11 | this.htmlElement.style.height = "100vh"; 12 | appendCustomHtmlElement(document.getElementById("preview"), this); 13 | 14 | this.htmlElement.addEventListener("dragover", (e) => { 15 | e.preventDefault(); 16 | }); 17 | 18 | this.htmlElement.addEventListener("dragenter", () => { 19 | this.hover(this, 2); 20 | }); 21 | 22 | this.htmlElement.addEventListener("mouseenter", () => { 23 | this.hover(this, 2); 24 | }); 25 | 26 | this.htmlElement.addEventListener("mouseleave", () => { 27 | this.unhover(this); 28 | }); 29 | 30 | this.htmlElement.addEventListener("dragleave", () => { 31 | this.unhover(this); 32 | }); 33 | 34 | this.htmlElement.addEventListener("drop", async (e) => { 35 | e.preventDefault(); 36 | const widgetType = e.dataTransfer.getData("widget-type"); 37 | await this.createWidget(widgetType); 38 | }); 39 | } 40 | 41 | private async createWidget(widgetType: string): Promise { 42 | let widget: Widget; 43 | 44 | switch (widgetType) { 45 | case String(TextboxWidget): 46 | widget = new TextboxWidget(); 47 | break; 48 | default: 49 | return; 50 | } 51 | 52 | this.addWidget(widget); 53 | } 54 | 55 | public addWidget(widget: Widget): void { 56 | createInspector(widget); 57 | appendCustomHtmlElement(this.htmlElement, widget); 58 | 59 | widget.htmlElement.addEventListener("mouseenter", () => { 60 | this.unhover(this); 61 | this.hover(widget); 62 | }); 63 | 64 | widget.htmlElement.addEventListener("mouseleave", () => { 65 | this.unhover(widget); 66 | this.hover(this, 2); 67 | }); 68 | 69 | state.widgets.push(widget); 70 | } 71 | 72 | private hover(el: CustomElement, borderSize = 1) { 73 | el.htmlElement.style.outline = `solid ${borderSize}px #0084ff`; 74 | } 75 | 76 | private unhover(el: CustomElement) { 77 | el.htmlElement.style.outline = "none"; 78 | } 79 | } 80 | 81 | export default OpalBody; 82 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/WidgetInspector.ts: -------------------------------------------------------------------------------- 1 | import CustomElement from "./CustomElement"; 2 | import OpalElement from "./Widgets/Widget"; 3 | import handleInspectorChange from "../util/handleInspectorChange"; 4 | import camelToCapitalised from "../util/camelToCapitalised"; 5 | import { 6 | WidgetProperty, 7 | WidgetPropertyChoice, 8 | WidgetPropertyType, 9 | } from "../types"; 10 | import setContentEditableCursorEnd from "../util/setContentEditableCursorEnd"; 11 | import Dropdown from "./Dropdown"; 12 | import Widget from "./Widgets/Widget"; 13 | 14 | interface CategoryProperty { 15 | propertyLabel: HTMLHeadingElement; 16 | inspectorProperty: HTMLElement; 17 | } 18 | 19 | interface Categories { 20 | [key: string]: { 21 | categoryProperties: CategoryProperty[]; 22 | priority: number; 23 | }; 24 | } 25 | 26 | const MIN_TEXT_SIZE = "1"; 27 | const MAX_TEXT_SIZE = "100"; 28 | 29 | export default class WidgetInspector extends CustomElement { 30 | constructor(private widget: Widget) { 31 | super(); 32 | this.htmlElement.className = "widget-inspector"; 33 | 34 | const categories: Categories = {}; 35 | 36 | for (const propertyKey in widget.propertyTypes) { 37 | const property = widget.properties[propertyKey]; 38 | const propertyType = widget.propertyTypes[propertyKey]; 39 | 40 | const propertyLabel = document.createElement("h4"); 41 | propertyLabel.innerText = camelToCapitalised(propertyKey); 42 | propertyLabel.style.color = "#C5C5C5"; 43 | propertyLabel.style.fontSize = "15px"; 44 | 45 | const inspectorProperty = this.createInspectorProperty( 46 | property, 47 | propertyType.type 48 | ); 49 | inspectorProperty.className = "widget-inspector-child"; 50 | 51 | if (!categories[property.category.label]) { 52 | categories[property.category.label] = { 53 | categoryProperties: [], 54 | priority: property.category.priority, 55 | }; 56 | } 57 | 58 | categories[property.category.label].categoryProperties.push({ 59 | propertyLabel, 60 | inspectorProperty, 61 | }); 62 | 63 | this.widget.htmlElement.addEventListener( 64 | "property-enabled", 65 | (e: CustomEvent) => { 66 | if (e.detail.propertyKey === propertyKey) { 67 | propertyLabel.style.display = "block"; 68 | inspectorProperty.style.display = "block"; 69 | } 70 | } 71 | ); 72 | 73 | this.widget.htmlElement.addEventListener( 74 | "property-disabled", 75 | (e: CustomEvent) => { 76 | if (e.detail.propertyKey === propertyKey) { 77 | propertyLabel.style.display = "none"; 78 | inspectorProperty.style.display = "none"; 79 | } 80 | } 81 | ); 82 | } 83 | 84 | const sortedCategories = Object.keys(categories).sort( 85 | (a, b) => categories[a].priority - categories[b].priority 86 | ); 87 | sortedCategories.forEach((categoryLabel) => { 88 | const dropdownChild = document.createElement("div"); 89 | dropdownChild.className = "inspector-property"; 90 | 91 | categories[categoryLabel].categoryProperties.forEach( 92 | ({ propertyLabel, inspectorProperty }) => { 93 | dropdownChild.appendChild(propertyLabel); 94 | dropdownChild.appendChild(inspectorProperty); 95 | } 96 | ); 97 | 98 | new Dropdown(categoryLabel, this.htmlElement, dropdownChild); 99 | }); 100 | } 101 | 102 | private createInspectorProperty( 103 | property: WidgetProperty, 104 | type: WidgetPropertyType 105 | ): HTMLElement { 106 | switch (type) { 107 | case WidgetPropertyType.TEXT_EDITABLE: 108 | return this.createTextEditable(property); 109 | case WidgetPropertyType.TEXT_SHORT: 110 | return this.createTextShort(property); 111 | case WidgetPropertyType.CHOICE: 112 | return this.createChoice(property); 113 | case WidgetPropertyType.NUMBER: 114 | return this.createNumber(property); 115 | case WidgetPropertyType.BOOLEAN: 116 | return this.createBoolean(property); 117 | default: 118 | break; 119 | } 120 | } 121 | 122 | private createTextEditable(property: WidgetProperty): HTMLElement { 123 | const inputElement = document.createElement("textarea"); 124 | 125 | if (property.value) inputElement.value = property.value; 126 | 127 | inputElement.addEventListener("input", () => { 128 | handleInspectorChange(property, inputElement.value); 129 | }); 130 | 131 | this.widget.htmlElement.addEventListener("input", () => { 132 | handleInspectorChange(property, this.widget.htmlElement.innerText); 133 | setContentEditableCursorEnd(this.widget.htmlElement); 134 | inputElement.value = this.widget.htmlElement.innerText; 135 | }); 136 | 137 | return inputElement; 138 | } 139 | 140 | private createTextShort(property: WidgetProperty): HTMLElement { 141 | const inputElement = document.createElement("input"); 142 | inputElement.type = "text"; 143 | 144 | if (property.value) inputElement.value = property.value; 145 | 146 | inputElement.addEventListener("input", () => { 147 | handleInspectorChange(property, inputElement.value); 148 | }); 149 | 150 | return inputElement; 151 | } 152 | 153 | private createChoice( 154 | property: WidgetProperty 155 | ): HTMLElement { 156 | const choiceElement = document.createElement("select"); 157 | 158 | for (const choiceKey in property.value.choiceEnum) { 159 | const choiceString: string = property.value.choiceEnum[choiceKey]; 160 | const optionElement = document.createElement("option"); 161 | 162 | optionElement.innerText = choiceString; 163 | choiceElement.appendChild(optionElement); 164 | } 165 | 166 | choiceElement.value = property.value.currentChoice; 167 | 168 | choiceElement.addEventListener("input", () => { 169 | handleInspectorChange(property, choiceElement.value); 170 | }); 171 | 172 | return choiceElement; 173 | } 174 | 175 | private createNumber(property: WidgetProperty): HTMLElement { 176 | const inputElement = document.createElement("input"); 177 | inputElement.type = "number"; 178 | inputElement.min = MIN_TEXT_SIZE; 179 | inputElement.max = MAX_TEXT_SIZE; 180 | 181 | inputElement.value = String(property.value); 182 | 183 | inputElement.addEventListener("input", () => { 184 | if (Number(inputElement.value) > Number(MAX_TEXT_SIZE)) 185 | inputElement.value = MAX_TEXT_SIZE; 186 | else if ( 187 | Number(inputElement.value) < Number(MIN_TEXT_SIZE) && 188 | inputElement.value !== "" 189 | ) 190 | inputElement.value = MIN_TEXT_SIZE; 191 | 192 | handleInspectorChange(property, Number(inputElement.value)); 193 | }); 194 | 195 | return inputElement; 196 | } 197 | 198 | private createBoolean(property: WidgetProperty): HTMLElement { 199 | const booleanElement = document.createElement("input"); 200 | booleanElement.type = "checkbox"; 201 | booleanElement.checked = property.value; 202 | 203 | booleanElement.addEventListener("input", () => { 204 | handleInspectorChange(property, booleanElement.checked); 205 | }); 206 | 207 | return booleanElement; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Widgets/Layout/ContainerWidget.ts: -------------------------------------------------------------------------------- 1 | import TextboxWidget from "../Text/TextboxWidget"; 2 | import Widget from "../Widget"; 3 | import appendCustomHtmlElement from "../../../util/appendCustomHtmlElement"; 4 | import { state } from "../../../util/state"; 5 | import { 6 | ContainerWidgetProperties, 7 | WidgetPropertyCategory, 8 | WidgetPropertyType, 9 | WidgetPropertyTypes, 10 | } from "../../../types"; 11 | import { createInspector } from "../../../scripts/inspector"; 12 | 13 | export default class ContainerWidget extends Widget { 14 | properties: ContainerWidgetProperties; 15 | public occupied: boolean; 16 | 17 | constructor() { 18 | const positionCategory: WidgetPropertyCategory = { 19 | label: "Position", 20 | priority: 1, 21 | }; 22 | 23 | const propertyTypes: WidgetPropertyTypes = { 24 | padding: { 25 | type: WidgetPropertyType.NUMBER, 26 | category: positionCategory, 27 | }, 28 | }; 29 | 30 | super("div", propertyTypes); 31 | 32 | this.properties.padding.handleInspectorChange = (value: number) => 33 | this.setPadding(value); 34 | 35 | this.htmlElement.style.height = "50px"; 36 | 37 | this.occupied = false; 38 | 39 | this.htmlElement.addEventListener("dragover", (e) => { 40 | if (this.occupied) return; 41 | e.preventDefault(); 42 | }); 43 | 44 | this.htmlElement.addEventListener("dragenter", () => { 45 | if (this.occupied) return; 46 | this.htmlElement.style.outline = "solid 1px #0084ff"; 47 | }); 48 | 49 | this.htmlElement.addEventListener("dragleave", () => { 50 | if (this.occupied) return; 51 | this.htmlElement.style.outline = "none"; 52 | }); 53 | 54 | this.htmlElement.addEventListener( 55 | "drop", 56 | async (e) => { 57 | if (this.occupied) return; 58 | e.preventDefault(); 59 | const widgetType = e.dataTransfer.getData("widget-type"); 60 | await this.createWidget(widgetType); 61 | }, 62 | { once: true } 63 | ); 64 | } 65 | 66 | private setPadding(value: number): void { 67 | this.properties.padding.value = value; 68 | this.htmlElement.style.padding = value + "px"; 69 | } 70 | 71 | private hover(widget: Widget) { 72 | widget.htmlElement.style.outline = "solid 1px #0084ff"; 73 | } 74 | 75 | private unhover(widget: Widget) { 76 | widget.htmlElement.style.outline = "none"; 77 | } 78 | 79 | private widgetAdded(): void { 80 | this.htmlElement.style.height = "auto"; 81 | 82 | this.htmlElement.addEventListener("mouseenter", () => { 83 | this.hover(this); 84 | }); 85 | 86 | this.htmlElement.addEventListener("mouseleave", () => { 87 | this.unhover(this); 88 | }); 89 | } 90 | 91 | private async createWidget(widgetType: string): Promise { 92 | let widget: Widget; 93 | 94 | switch (widgetType) { 95 | case String(TextboxWidget): 96 | widget = new TextboxWidget(); 97 | break; 98 | default: 99 | return; 100 | } 101 | 102 | await this.addWidget(widget); 103 | } 104 | 105 | public async addWidget(widget: Widget): Promise { 106 | createInspector(widget); 107 | appendCustomHtmlElement(this.htmlElement, widget); 108 | 109 | widget.htmlElement.addEventListener("mouseenter", () => { 110 | this.unhover(this); 111 | this.hover(widget); 112 | }); 113 | 114 | widget.htmlElement.addEventListener("mouseleave", () => { 115 | this.unhover(widget); 116 | this.hover(this); 117 | }); 118 | 119 | state.widgets.push(widget); 120 | this.occupied = true; 121 | 122 | this.widgetAdded(); 123 | } 124 | 125 | public loadProperties(): void { 126 | this.setPadding(this.properties.padding.value); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Widgets/Text/TextboxWidget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WidgetPropertyCategory, 3 | WidgetPropertyType, 4 | WidgetPropertyTypes, 5 | FontWeight, 6 | TextWidgetProperties, 7 | TextType, 8 | } from "../../../types"; 9 | import Widget from "../Widget"; 10 | 11 | const DEFAULT_TEXT_SIZE = 18; 12 | 13 | export default class TextboxWidget extends Widget { 14 | properties: TextWidgetProperties; 15 | htmlElement: HTMLDivElement; 16 | 17 | constructor() { 18 | const generalCategory: WidgetPropertyCategory = { 19 | label: "General", 20 | priority: 1, 21 | }; 22 | const typographyCategory: WidgetPropertyCategory = { 23 | label: "Typography", 24 | priority: 2, 25 | }; 26 | const seoCategory: WidgetPropertyCategory = { 27 | label: "SEO", 28 | priority: 3, 29 | }; 30 | 31 | const propertyTypes: WidgetPropertyTypes = { 32 | text: { 33 | type: WidgetPropertyType.TEXT_EDITABLE, 34 | category: generalCategory, 35 | }, 36 | size: { 37 | type: WidgetPropertyType.NUMBER, 38 | category: typographyCategory, 39 | }, 40 | weight: { 41 | type: WidgetPropertyType.CHOICE, 42 | category: typographyCategory, 43 | choiceEnum: FontWeight, 44 | }, 45 | type: { 46 | type: WidgetPropertyType.CHOICE, 47 | category: seoCategory, 48 | choiceEnum: TextType, 49 | }, 50 | }; 51 | 52 | super("div", propertyTypes); 53 | 54 | this.htmlElement.className = "text-box-widget"; 55 | this.htmlElement.contentEditable = "true"; 56 | 57 | this.setText("Textbox"); 58 | this.setType(TextType.PARAGRAPH); 59 | this.setSize(DEFAULT_TEXT_SIZE); 60 | this.setWeight(FontWeight.FOUR_HUNDRED); 61 | 62 | this.properties.text.handleInspectorChange = (value: string) => 63 | this.setText(value); 64 | this.properties.size.handleInspectorChange = (value: number) => 65 | this.setSize(value); 66 | this.properties.weight.handleInspectorChange = (value: FontWeight) => 67 | this.setWeight(value); 68 | this.properties.type.handleInspectorChange = (value: TextType) => 69 | this.setType(value); 70 | } 71 | 72 | public setText(value: string): void { 73 | this.properties.text.value = value; 74 | this.htmlElement.innerText = value; 75 | } 76 | 77 | public setSize(value: number): void { 78 | this.htmlElement.style.fontSize = value + "px"; 79 | this.properties.size.value = value; 80 | } 81 | 82 | public setType(value: TextType): void { 83 | this.properties.type.value.currentChoice = value; 84 | } 85 | 86 | public setWeight(value: FontWeight): void { 87 | this.properties.weight.value.currentChoice = value; 88 | this.htmlElement.style.fontWeight = String(value); 89 | } 90 | 91 | public loadProperties(): void { 92 | this.setText(this.properties.text.value); 93 | this.setSize(this.properties.size.value); 94 | this.setWeight( 95 | this.properties.weight.value.currentChoice as FontWeight 96 | ); 97 | this.setType(this.properties.type.value.currentChoice as TextType); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/pages/editor/CustomHtmlElements/Widgets/Widget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WidgetProperties, 3 | WidgetProperty, 4 | WidgetPropertyType, 5 | WidgetPropertyTypes, 6 | WidgetSave, 7 | WidgetType, 8 | } from "../../types"; 9 | import CustomElement from "../CustomElement"; 10 | import TextboxWidget from "./Text/TextboxWidget"; 11 | 12 | export default abstract class Widget extends CustomElement { 13 | public properties: WidgetProperties; 14 | 15 | constructor( 16 | type: string = "div", 17 | public propertyTypes: WidgetPropertyTypes 18 | ) { 19 | super(type); 20 | this.properties = {}; 21 | this.propertyTypes.identifier = { 22 | type: WidgetPropertyType.TEXT_SHORT, 23 | category: { label: "Widget Settings", priority: 0 }, 24 | }; 25 | 26 | this.initialiseProperties(); 27 | 28 | this.properties.identifier.handleInspectorChange = (value: string) => 29 | (this.properties.identifier.value = value); 30 | } 31 | 32 | static generateFromSave(widgetSave: WidgetSave): Widget { 33 | let widget: Widget; 34 | switch (widgetSave.type) { 35 | case WidgetType.TextBox: 36 | widget = new TextboxWidget(); 37 | break; 38 | default: 39 | throw new Error( 40 | `Could not find widget with given type '${widgetSave.type}'.` 41 | ); 42 | } 43 | 44 | for (const propertyKey in widgetSave.properties) { 45 | const property = widgetSave.properties[propertyKey]; 46 | widget.properties[propertyKey].value = property; 47 | } 48 | 49 | widget.propertyTypes = widgetSave.propertyTypes; 50 | 51 | return widget; 52 | } 53 | 54 | public disableProperty(propertyKey: string): void { 55 | this.properties[propertyKey].disabled = true; 56 | const disableEvent = new CustomEvent("property-disabled", { 57 | detail: { propertyKey }, 58 | }); 59 | this.htmlElement.dispatchEvent(disableEvent); 60 | } 61 | 62 | public enableProperty(propertyKey: string): void { 63 | this.properties[propertyKey].disabled = false; 64 | const disableEvent = new CustomEvent("property-enabled", { 65 | detail: { propertyKey }, 66 | }); 67 | this.htmlElement.dispatchEvent(disableEvent); 68 | } 69 | 70 | private initialiseProperties(): void { 71 | for (const propertyKey in this.propertyTypes) { 72 | const propertyType = this.propertyTypes[propertyKey]; 73 | 74 | const property: WidgetProperty = { 75 | disabled: false, 76 | category: propertyType.category, 77 | }; 78 | 79 | if (propertyType.type === WidgetPropertyType.CHOICE) { 80 | if (!propertyType.choiceEnum) 81 | throw new Error("Choice property has no choice enum."); 82 | 83 | property.value = { choiceEnum: propertyType.choiceEnum }; 84 | } 85 | 86 | this.properties[propertyKey] = property; 87 | } 88 | } 89 | 90 | public abstract loadProperties(): void; 91 | } 92 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/Alert.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/Cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/DropdownArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/Layers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/Page.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/pages/editor/icons/Plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/pages/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Opal 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/app/pages/editor/index.ts: -------------------------------------------------------------------------------- 1 | import createSidenav from "./scripts/sidenav"; 2 | import attatchEventListeners from "./scripts/attatchEventListeners"; 3 | import attachIpcListeners from "./scripts/attatchIpcListeners"; 4 | import load from "./scripts/load"; 5 | import { state } from "./util/state"; 6 | 7 | import "./scripts/save"; 8 | import "./scripts/build"; 9 | import OpalBody from "./CustomHtmlElements/OpalBody"; 10 | 11 | const loadEditor = async (): Promise => { 12 | const opalBody = new OpalBody(); 13 | await load(opalBody); 14 | 15 | createSidenav(); 16 | 17 | attatchEventListeners(); 18 | attachIpcListeners(); 19 | 20 | ipc.invoke("editor-load", state.currentProjectDirectory); 21 | 22 | addEventListener("beforeunload", () => { 23 | ipc.invoke("editor-unload"); 24 | }); 25 | }; 26 | 27 | loadEditor(); 28 | -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/attatchEventListeners.ts: -------------------------------------------------------------------------------- 1 | const attatchEventListeners = (): void => { 2 | history.pushState(null, null, document.title); 3 | addEventListener("popstate", () => { 4 | history.pushState(null, null, document.title); 5 | }) 6 | } 7 | 8 | export default attatchEventListeners; -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/attatchIpcListeners.ts: -------------------------------------------------------------------------------- 1 | import { state } from "../util/state"; 2 | import build from "./build"; 3 | 4 | const attatchIpcListeners = (): void => { 5 | ipc.on("open-menu", () => { 6 | location.href = "../menu/index.html"; 7 | }) 8 | 9 | ipc.on("new-project", () => { 10 | location.href = "../new-project/index.html"; 11 | }) 12 | 13 | ipc.on("preview-site", async () => { 14 | await build(); 15 | shell.openExternal("http://localhost:8080"); 16 | }) 17 | 18 | ipc.on("open-project-directory", async () => { 19 | shell.showItemInFolder(state.currentProjectDirectory); 20 | }) 21 | } 22 | 23 | export default attatchIpcListeners; -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import TextboxWidget from "../CustomHtmlElements/Widgets/Text/TextboxWidget"; 2 | import { TextType } from "../types"; 3 | import { state } from "../util/state"; 4 | import toDashes from "../util/toDashes"; 5 | import save from "./save"; 6 | 7 | const baseHTML = 8 | ""; 9 | let body = ""; 10 | const endingHTML = ""; 11 | 12 | const appendTextElementSource = (widget: TextboxWidget): void => { 13 | let widgetType: string; 14 | switch (widget.properties.type.value.currentChoice) { 15 | case TextType.HEADING_ONE: 16 | widgetType = "h1"; 17 | break; 18 | case TextType.HEADING_TWO: 19 | widgetType = "h2"; 20 | break; 21 | case TextType.HEADING_THREE: 22 | widgetType = "h3"; 23 | break; 24 | case TextType.HEADING_FOUR: 25 | widgetType = "h4"; 26 | break; 27 | case TextType.HEADING_FIVE: 28 | widgetType = "h5"; 29 | break; 30 | case TextType.HEADING_SIX: 31 | widgetType = "h6"; 32 | break; 33 | case TextType.PARAGRAPH: 34 | widgetType = "p"; 35 | break; 36 | default: 37 | return; 38 | } 39 | 40 | body += `<${widgetType} id="${toDashes( 41 | widget.properties.identifier.value 42 | )}" style=\"font-weight: ${String( 43 | widget.properties.weight.value.currentChoice 44 | )};${ 45 | widget.properties.size.value 46 | ? `font-size: ${widget.properties.size.value}px;` 47 | : "" 48 | }\">${widget.properties.text.value}`; 49 | }; 50 | 51 | const build = async (): Promise => { 52 | await save(); 53 | 54 | state.widgets.forEach((widget) => { 55 | if (widget instanceof TextboxWidget) appendTextElementSource(widget); 56 | }); 57 | 58 | await fs.writeFile( 59 | `${state.currentProjectDirectory}/index.html`, 60 | baseHTML + body + endingHTML 61 | ); 62 | body = ""; 63 | }; 64 | 65 | ipc.on("build", build); 66 | 67 | export default build; 68 | -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/inspector.ts: -------------------------------------------------------------------------------- 1 | import WidgetInspector from "../CustomHtmlElements/WidgetInspector"; 2 | import Menu from "../CustomHtmlElements/Menus/Menu"; 3 | import ContainerWidget from "../CustomHtmlElements/Widgets/Layout/ContainerWidget"; 4 | import Widget from "../CustomHtmlElements/Widgets/Widget"; 5 | import appendCustomHtmlElement from "../util/appendCustomHtmlElement"; 6 | 7 | const inspector = new Menu("No element selected", false); 8 | appendCustomHtmlElement( 9 | document.getElementsByTagName("main").item(0), 10 | inspector 11 | ); 12 | 13 | const widgetInspectors: WidgetInspector[] = []; 14 | 15 | const showWidgetInspector = (widgetInspector: WidgetInspector) => { 16 | widgetInspectors.forEach((wInspector) => { 17 | wInspector.htmlElement.style.display = "none"; 18 | }); 19 | 20 | widgetInspector.htmlElement.style.display = "block"; 21 | }; 22 | 23 | const createInspector = (widget: Widget) => { 24 | const widgetInspector = new WidgetInspector(widget); 25 | widgetInspectors.push(widgetInspector); 26 | 27 | showWidgetInspector(widgetInspector); 28 | 29 | widget.htmlElement.addEventListener("click", () => { 30 | showWidgetInspector(widgetInspector); 31 | }); 32 | 33 | inspector.titleText = widget.constructor.name; 34 | 35 | appendCustomHtmlElement(inspector.htmlElement, widgetInspector); 36 | }; 37 | 38 | export { inspector, createInspector }; 39 | -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/load.ts: -------------------------------------------------------------------------------- 1 | import OpalBody from "../CustomHtmlElements/OpalBody"; 2 | import Widget from "../CustomHtmlElements/Widgets/Widget"; 3 | import { ProjectInfo } from "../types"; 4 | import { state } from "../util/state"; 5 | 6 | const load = async (opalBody: OpalBody): Promise => { 7 | const projectInfoRaw = await fs.readFile( 8 | `${state.currentProjectDirectory}/project-info.json`, 9 | "utf8" 10 | ); 11 | const projectInfo = JSON.parse(projectInfoRaw); 12 | state.projectInfo = projectInfo; 13 | 14 | document.title = `Opal - ${projectInfo.name}`; 15 | 16 | state.projectInfo.widgets.forEach(async (widget) => { 17 | const generatedWidget = Widget.generateFromSave(widget); 18 | generatedWidget.loadProperties(); 19 | await opalBody.addWidget(generatedWidget); 20 | }); 21 | }; 22 | 23 | export default load; 24 | -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/save.ts: -------------------------------------------------------------------------------- 1 | import TextBoxWidget from "../CustomHtmlElements/Widgets/Text/TextboxWidget"; 2 | import { WidgetSave } from "../types"; 3 | import { state } from "../util/state"; 4 | import toCamel from "../util/toCamel"; 5 | import toDashes from "../util/toDashes"; 6 | 7 | const apiTemplate = fsSync.readFileSync("./src/opalApiTemplate.js", "utf-8"); 8 | 9 | const save = async (): Promise => { 10 | document.body.style.cursor = "progress"; 11 | 12 | // reset all elements so we can add them back 13 | state.projectInfo.widgets = []; 14 | 15 | let opalSrc = ` 16 | ${apiTemplate} 17 | 18 | export const widgets = { 19 | `; 20 | 21 | state.widgets.forEach((widget) => { 22 | const widgetSave = { properties: {}, propertyTypes: {} }; 23 | widgetSave.propertyTypes = widget.propertyTypes; 24 | 25 | for (const propertyKey in widget.properties) { 26 | widgetSave.type = widget.constructor.name; 27 | widgetSave.properties[propertyKey] = 28 | widget.properties[propertyKey].value; 29 | } 30 | 31 | state.projectInfo.widgets.push(widgetSave); 32 | 33 | if (widget.properties.identifier.value) { 34 | if (widget instanceof TextBoxWidget) { 35 | opalSrc += `${toCamel( 36 | widget.properties.identifier.value 37 | )}: new TextBoxWidget("${toDashes( 38 | widget.properties.identifier.value 39 | )}"), `; 40 | } 41 | } 42 | }); 43 | 44 | opalSrc += "};"; 45 | 46 | await fs.writeFile( 47 | `${state.currentProjectDirectory}/project-info.json`, 48 | JSON.stringify(state.projectInfo) 49 | ); 50 | await fs.writeFile(`${state.currentProjectDirectory}/src/opal.js`, opalSrc); 51 | 52 | setTimeout(() => { 53 | document.body.style.cursor = "default"; 54 | }, 100); 55 | }; 56 | 57 | ipc.on("save", save); 58 | 59 | export default save; 60 | -------------------------------------------------------------------------------- /src/app/pages/editor/scripts/sidenav.ts: -------------------------------------------------------------------------------- 1 | import IconButton from "../CustomHtmlElements/IconButton"; 2 | import AddWidgetMenu from "../CustomHtmlElements/Menus/AddWidgetMenu"; 3 | import SidenavMenu from "../CustomHtmlElements/Menus/Menu"; 4 | import appendCustomHtmlElement from "../util/appendCustomHtmlElement"; 5 | 6 | const createSidenav = (): void => { 7 | interface SidenavButton { 8 | svgSrc: string; 9 | menu: SidenavMenu; 10 | container?: HTMLDivElement; 11 | isToggled?: boolean; 12 | } 13 | 14 | const sidenav = document.getElementById("sidenav"); 15 | const main = document.getElementsByTagName("main").item(0); 16 | const preview = document.getElementById("preview"); 17 | 18 | const addWidgetMenu = new AddWidgetMenu(); 19 | main.insertBefore(addWidgetMenu.htmlElement, preview); 20 | 21 | const sidenavButtons: SidenavButton[] = [ 22 | { 23 | svgSrc: "./icons/Plus.svg", 24 | menu: addWidgetMenu, 25 | }, 26 | { 27 | svgSrc: "./icons/Alert.svg", 28 | menu: new SidenavMenu("Menu"), 29 | }, 30 | { 31 | svgSrc: "./icons/Layers.svg", 32 | menu: new SidenavMenu("Menu"), 33 | }, 34 | { 35 | svgSrc: "./icons/Page.svg", 36 | menu: new SidenavMenu("Menu"), 37 | }, 38 | ]; 39 | 40 | const closeAllSidenavMenus = () => { 41 | sidenavButtons.forEach((sidenavButton, idx) => { 42 | sidenavButton.container.style.backgroundColor = "#404040"; 43 | sidenavButton.menu.close(); 44 | sidenavButtons[idx].isToggled = false; 45 | }); 46 | }; 47 | 48 | const toggleSidenavButton = (buttonIndex: number) => { 49 | const sidenavButton = sidenavButtons[buttonIndex]; 50 | if (sidenavButton.isToggled) { 51 | sidenavButton.container.style.backgroundColor = "#404040"; 52 | sidenavButton.menu.close(); 53 | } else { 54 | closeAllSidenavMenus(); 55 | sidenavButton.container.style.backgroundColor = "#4D4D4D"; 56 | sidenavButton.menu.open(); 57 | } 58 | 59 | sidenavButtons[buttonIndex].isToggled = 60 | !sidenavButtons[buttonIndex].isToggled; 61 | }; 62 | 63 | sidenavButtons.forEach((sidenavButton, idx) => { 64 | const buttonContainer = document.createElement("div"); 65 | sidenavButtons[idx].container = buttonContainer; 66 | sidenavButtons[idx].isToggled = false; 67 | buttonContainer.className = "sidenav-button-container"; 68 | 69 | const button = new IconButton(sidenavButton.svgSrc); 70 | appendCustomHtmlElement(buttonContainer, button); 71 | 72 | // Figure out how to change image fill on hover 73 | // buttonContainer.addEventListener("mouseenter", () => { 74 | // button.element.style.fill = "#ECECEC"; 75 | // }) 76 | 77 | // buttonContainer.addEventListener("mouseleave", () => { 78 | // button.element.style.fill = "#D0D0D0"; 79 | // }) 80 | 81 | buttonContainer.addEventListener("click", () => 82 | toggleSidenavButton(idx) 83 | ); 84 | sidenavButton.menu.htmlElement.addEventListener("close", () => 85 | toggleSidenavButton(idx) 86 | ); 87 | 88 | sidenav.appendChild(buttonContainer); 89 | }); 90 | }; 91 | 92 | export default createSidenav; 93 | -------------------------------------------------------------------------------- /src/app/pages/editor/styles/custom-widgets.css: -------------------------------------------------------------------------------- 1 | .block { 2 | width: 260px; 3 | height: 100vh; 4 | background-color: #404040; 5 | border: 1px solid #2D2D2D; 6 | color: white; 7 | } 8 | 9 | .sidenav-item-title { 10 | background-color: #4D4D4D; 11 | width: 100%; 12 | height: 40px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: space-between; 16 | } 17 | 18 | .icon-button { 19 | cursor: pointer; 20 | fill: white; 21 | } 22 | 23 | .dropdown { 24 | background-color: #242424; 25 | width: 100%; 26 | height: 40px; 27 | display: flex; 28 | align-items: center; 29 | cursor: pointer; 30 | } 31 | 32 | .grid-container { 33 | display: grid; 34 | grid-template-columns: repeat(3, 1fr); 35 | width: 100%; 36 | } 37 | 38 | .grid-item { 39 | height: 85px; 40 | border-right: 1px solid #2D2D2D; 41 | border-bottom: 1px solid #2D2D2D; 42 | text-align: center; 43 | display: flex; 44 | justify-content: center; 45 | flex-direction: column; 46 | } -------------------------------------------------------------------------------- /src/app/pages/editor/styles/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | user-select: none; 6 | overflow: hidden; 7 | font-family: 'Lato', sans-serif; 8 | } 9 | 10 | main { 11 | display: flex; 12 | } 13 | 14 | h1 { 15 | margin: 0; 16 | } 17 | 18 | h2 { 19 | margin: 0; 20 | } 21 | 22 | h3 { 23 | margin: 0; 24 | } 25 | 26 | h4 { 27 | margin: 0; 28 | font-weight: 400; 29 | font-size: 20px; 30 | } 31 | 32 | h5 { 33 | margin: 0; 34 | font-weight: 700; 35 | font-size: 16px; 36 | } 37 | 38 | h6 { 39 | margin: 0; 40 | } 41 | 42 | p { 43 | margin: 0; 44 | } 45 | 46 | input[type=text], input[type=number], textarea, select { 47 | width: 180px; 48 | background-color: #242424; 49 | border: solid 1px black; 50 | border-radius: 3px; 51 | outline: none; 52 | color: white; 53 | } 54 | 55 | input[type=text], input[type=number] { 56 | height: 20px; 57 | } 58 | 59 | textarea { 60 | height: 50px; 61 | font-family: 'Lato', sans-serif; 62 | } -------------------------------------------------------------------------------- /src/app/pages/editor/styles/inspector.css: -------------------------------------------------------------------------------- 1 | .inspector-property { 2 | text-align: center; 3 | display: flex; 4 | align-items: center; 5 | } 6 | 7 | .widget-inspector-child { 8 | margin: 15px auto; 9 | } 10 | 11 | .widget-inspector textarea { 12 | resize: none; 13 | outline: none; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/pages/editor/styles/preview.css: -------------------------------------------------------------------------------- 1 | .preview { 2 | flex: 1; 3 | font-family: serif; 4 | } -------------------------------------------------------------------------------- /src/app/pages/editor/styles/sidenav.css: -------------------------------------------------------------------------------- 1 | .sidenav { 2 | height: 100vh; 3 | width: 40px; 4 | border: solid 1px #2D2D2D; 5 | background-color: #404040; 6 | } 7 | 8 | .sidenav-button-container { 9 | width: 100%; 10 | height: 40px; 11 | text-align: center; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | border-bottom: solid 1px #2D2D2D; 16 | cursor: pointer; 17 | } 18 | 19 | .sidenav-button-container:hover { 20 | background-color: #4D4D4D; 21 | } -------------------------------------------------------------------------------- /src/app/pages/editor/styles/widgets.css: -------------------------------------------------------------------------------- 1 | .element-containers { 2 | width: 100%; 3 | } 4 | 5 | .text-box-element { 6 | cursor: text; 7 | outline: none; 8 | } 9 | 10 | .text-element-h1 { 11 | font-size: 60px; 12 | } 13 | 14 | .text-element-h2 { 15 | font-size: 50px; 16 | } 17 | 18 | .text-element-h3 { 19 | font-size: 45px; 20 | } 21 | 22 | .text-element-h4 { 23 | font-size: 40px; 24 | } 25 | 26 | .text-element-h5 { 27 | font-size: 35px; 28 | } 29 | 30 | .text-element-h6 { 31 | font-size: 30px; 32 | } 33 | 34 | .text-element-p { 35 | font-size: 18px; 36 | } -------------------------------------------------------------------------------- /src/app/pages/editor/types.ts: -------------------------------------------------------------------------------- 1 | import { IpcRenderer } from "electron"; 2 | import * as filesys from "fs/promises"; 3 | import * as filesysSync from "fs"; 4 | import { shell as sh } from "electron"; 5 | 6 | export type { OpenDialogOptions } from "electron"; 7 | 8 | declare global { 9 | const ipc: IpcRenderer; 10 | const fs: typeof filesys; 11 | const fsSync: typeof filesysSync; 12 | const shell: { 13 | openExternal: typeof sh.openExternal; 14 | showItemInFolder: typeof sh.showItemInFolder; 15 | }; 16 | } 17 | 18 | export enum WidgetPropertyType { 19 | TEXT_SHORT, 20 | TEXT_EDITABLE, 21 | CHOICE, 22 | NUMBER, 23 | BOOLEAN, 24 | } 25 | 26 | export interface WidgetPropertyTypes { 27 | [key: string]: { 28 | type: WidgetPropertyType; 29 | category: WidgetPropertyCategory; 30 | choiceEnum?: any; 31 | }; 32 | } 33 | 34 | export interface WidgetPropertyChoice { 35 | currentChoice?: string; 36 | choiceEnum: any; 37 | } 38 | 39 | export interface WidgetProperty { 40 | value?: ValueType; 41 | disabled: boolean; 42 | category: WidgetPropertyCategory; 43 | handleInspectorChange?: Function; 44 | } 45 | 46 | export interface WidgetProperties { 47 | [key: string]: WidgetProperty; 48 | identifier?: WidgetProperty; 49 | } 50 | 51 | export interface TextWidgetProperties extends WidgetProperties { 52 | text: WidgetProperty; 53 | type: WidgetProperty; 54 | size: WidgetProperty; 55 | weight: WidgetProperty; 56 | resizeToType: WidgetProperty; 57 | } 58 | 59 | export interface ContainerWidgetProperties extends WidgetProperties { 60 | padding: WidgetProperty; 61 | } 62 | 63 | export interface WidgetSave { 64 | type: string; 65 | properties: any; 66 | propertyTypes: any; 67 | } 68 | 69 | export interface ProjectInfo { 70 | name: string; 71 | isOpal?: boolean; 72 | widgets: WidgetSave[]; 73 | } 74 | 75 | export enum TextType { 76 | HEADING_ONE = "Heading One", 77 | HEADING_TWO = "Heading Two", 78 | HEADING_THREE = "Heading Three", 79 | HEADING_FOUR = "Heading Four", 80 | HEADING_FIVE = "Heading Five", 81 | HEADING_SIX = "Heading Six", 82 | PARAGRAPH = "Paragraph", 83 | } 84 | 85 | export enum FontWeight { 86 | ONE_HUNDRED = "100", 87 | TWO_HUNDRED = "200", 88 | THREE_HUNDRED = "300", 89 | FOUR_HUNDRED = "400", 90 | FIVE_HUNDRED = "500", 91 | SIX_HUNDRED = "600", 92 | SEVEN_HUNDRED = "700", 93 | EIGHT_HUNDRED = "800", 94 | NINE_HUNDRED = "900", 95 | } 96 | 97 | export interface WidgetPropertyCategory { 98 | label: string; 99 | priority: number; 100 | } 101 | 102 | export enum WidgetType { 103 | TextBox = "TextboxWidget", 104 | } 105 | -------------------------------------------------------------------------------- /src/app/pages/editor/util/appendCustomHtmlElement.ts: -------------------------------------------------------------------------------- 1 | import CustomElement from "../CustomHtmlElements/CustomElement"; 2 | 3 | /** 4 | * Append `customWidget.element` as a child of `destination` 5 | * @param destination Destination HTMLElement 6 | * @param customElement Custom opal element 7 | */ 8 | const appendCustomHtmlElement = ( 9 | destination: HTMLElement | Node, 10 | customElement: CustomElement 11 | ): void => { 12 | destination.appendChild(customElement.htmlElement); 13 | }; 14 | 15 | export default appendCustomHtmlElement; 16 | -------------------------------------------------------------------------------- /src/app/pages/editor/util/camelToCapitalised.ts: -------------------------------------------------------------------------------- 1 | const camelToCapitalised = (camel: string): string => { 2 | return camel.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()); 3 | } 4 | 5 | export default camelToCapitalised; -------------------------------------------------------------------------------- /src/app/pages/editor/util/handleInspectorChange.ts: -------------------------------------------------------------------------------- 1 | import { WidgetProperty } from "../types"; 2 | 3 | const handleInspectorChange = ( 4 | property: WidgetProperty, 5 | ...data: any[] 6 | ) => { 7 | if (property.handleInspectorChange) property.handleInspectorChange(...data); 8 | }; 9 | 10 | export default handleInspectorChange; 11 | -------------------------------------------------------------------------------- /src/app/pages/editor/util/pxToNumber.ts: -------------------------------------------------------------------------------- 1 | const pxToNumber = (pxString: string): number => { 2 | return Number(pxString.substring(0, pxString.length - 2)); 3 | } 4 | 5 | export default pxToNumber; -------------------------------------------------------------------------------- /src/app/pages/editor/util/setContentEditableCursorEnd.ts: -------------------------------------------------------------------------------- 1 | const setContentEditableCursorEnd = (element: HTMLElement) => { 2 | const selection = getSelection(); 3 | const range = document.createRange(); 4 | 5 | selection.removeAllRanges(); 6 | range.selectNodeContents(element); 7 | range.collapse(false); 8 | selection.addRange(range); 9 | element.focus(); 10 | } 11 | 12 | export default setContentEditableCursorEnd; -------------------------------------------------------------------------------- /src/app/pages/editor/util/state.ts: -------------------------------------------------------------------------------- 1 | import Widget from "../CustomHtmlElements/Widgets/Widget"; 2 | import { ProjectInfo } from "../types"; 3 | 4 | interface State { 5 | currentProjectDirectory: string; 6 | widgets: Widget[]; 7 | projectInfo: ProjectInfo; 8 | } 9 | 10 | export const state: State = { 11 | currentProjectDirectory: localStorage.getItem("currentProjectDirectory"), 12 | widgets: [], 13 | projectInfo: { name: "Unknown", widgets: [] }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/pages/editor/util/toCamel.ts: -------------------------------------------------------------------------------- 1 | const toCamel = (text: string): string => { 2 | return text.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => { 3 | return index === 0 ? word.toLowerCase() : word.toUpperCase(); 4 | }).replace(/\s+/g, ""); 5 | } 6 | 7 | export default toCamel; -------------------------------------------------------------------------------- /src/app/pages/editor/util/toDashes.ts: -------------------------------------------------------------------------------- 1 | const toDashes = (text: string): string => { 2 | if (typeof text !== "string") return ""; 3 | return text.split(" ").join("-").toLowerCase(); 4 | } 5 | 6 | export default toDashes; -------------------------------------------------------------------------------- /src/app/pages/menu/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Opal 8 | 9 | 10 | 11 |
12 |

Main Menu

13 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/app/pages/menu/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenDialogOptions, ProjectInfo } from "../editor/types"; 2 | 3 | const openProjectButton = document.getElementById("open-project-btn"); 4 | 5 | openProjectButton.addEventListener("click", async () => { 6 | const dialogChoice = await ipc.invoke("request-dialog-choice", { properties: ["openDirectory"] } as OpenDialogOptions); 7 | const projectInfoRaw = await fs.readFile(`${dialogChoice}/project-info.json`, "utf8"); 8 | const projectInfo: ProjectInfo = JSON.parse(projectInfoRaw); 9 | 10 | if (projectInfo.isOpal) { 11 | localStorage.setItem("currentProjectDirectory", dialogChoice); 12 | location.href = "../editor/index.html"; 13 | } else { 14 | alert("ERROR: Project is not an Opal project."); 15 | } 16 | }) -------------------------------------------------------------------------------- /src/app/pages/menu/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | user-select: none; 6 | overflow: hidden; 7 | font-family: 'Lato', sans-serif; 8 | background-color: rgb(0, 0, 31); 9 | color: white; 10 | } 11 | 12 | h1 { 13 | font-size: 80px; 14 | } 15 | 16 | main { 17 | margin-top: 20px; 18 | text-align: center; 19 | } 20 | 21 | button { 22 | width: 250px; 23 | height: 50px; 24 | outline: none; 25 | border: none; 26 | border-radius: 5px; 27 | margin: 0px 10px; 28 | font-size: 20px; 29 | background-color: white; 30 | cursor: pointer; 31 | font-family: 'Lato', sans-serif; 32 | } -------------------------------------------------------------------------------- /src/app/pages/new-project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Opal 8 | 9 | 10 | 11 |

New Project

12 |
13 |

Project Name

14 | 20 |

Project Path

21 | 27 |
28 | 29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/app/pages/new-project/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenDialogOptions, ProjectInfo } from "../editor/types"; 2 | 3 | const form = document.getElementById("new-project-form"); 4 | const projectDirectoryInput = ( 5 | document.getElementById("project-directory-input") 6 | ); 7 | 8 | const projectDirDialogButton = ( 9 | document.getElementById("project-dir-dialog-button") 10 | ); 11 | projectDirDialogButton.addEventListener("click", async (e) => { 12 | e.preventDefault(); 13 | const dialogChoice = await ipc.invoke("request-dialog-choice", { 14 | properties: ["openDirectory"], 15 | } as OpenDialogOptions); 16 | if (!dialogChoice) return; 17 | projectDirectoryInput.value = dialogChoice; 18 | }); 19 | 20 | form.addEventListener("submit", async (e) => { 21 | e.preventDefault(); 22 | const projectName = ( 23 | document.getElementById("project-name-input") as HTMLInputElement 24 | ).value; 25 | const rootDir = projectDirectoryInput.value; 26 | 27 | try { 28 | await fs.access(rootDir); 29 | } catch { 30 | await fs.mkdir(rootDir); 31 | } 32 | 33 | if ((await fs.readdir(rootDir)).length !== 0) { 34 | alert("ERROR: Directory must be empty."); 35 | return; 36 | } 37 | 38 | const projectInfo = { 39 | name: projectName, 40 | widgets: [], 41 | isOpal: true, 42 | }; 43 | 44 | await fs.writeFile( 45 | `${rootDir}/project-info.json`, 46 | JSON.stringify(projectInfo) 47 | ); 48 | await fs.chmod(`${rootDir}/project-info.json`, 444); 49 | 50 | await fs.mkdir(`${rootDir}/src`); 51 | await fs.writeFile(`${rootDir}/src/opal.js`, "export const widgets = {};"); 52 | await fs.writeFile(`${rootDir}/src/index.js`, ""); 53 | 54 | localStorage.setItem("currentProjectDirectory", rootDir); 55 | location.href = "../editor/index.html"; 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/pages/new-project/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | user-select: none; 6 | overflow: hidden; 7 | font-family: 'Lato', sans-serif; 8 | background-color: rgb(0, 0, 31); 9 | color: white; 10 | padding: 20px; 11 | } 12 | 13 | input { 14 | outline: none; 15 | border: none; 16 | width: 300px; 17 | height: 40px; 18 | padding: 0 5px; 19 | font-size: 16px; 20 | border-radius: 5px; 21 | } 22 | 23 | h3 { 24 | font-size: 25px; 25 | padding: 0; 26 | margin: 15px 0; 27 | } 28 | 29 | button { 30 | width: 250px; 31 | outline: none; 32 | border: none; 33 | border-radius: 5px; 34 | margin: 0px 10px; 35 | font-size: 20px; 36 | background-color: white; 37 | cursor: pointer; 38 | font-family: 'Lato', sans-serif; 39 | margin: 0; 40 | } 41 | 42 | #project-dir-dialog-button { 43 | width: 40px; 44 | height: 40px; 45 | } 46 | 47 | .submit-button { 48 | margin-top: 20px; 49 | height: 50px; 50 | } -------------------------------------------------------------------------------- /src/electron/src/attachIpcListeners.ts: -------------------------------------------------------------------------------- 1 | import { dialog, ipcMain } from "electron"; 2 | import * as express from "express"; 3 | import { Server } from "http"; 4 | 5 | let openServer: Server; 6 | 7 | const attatchIpcListeners = (): void => { 8 | ipcMain.handle("request-dialog-choice", async (_, options) => { 9 | const dialogChoice = await dialog.showOpenDialog(options); 10 | return dialogChoice.filePaths[0]; 11 | }); 12 | 13 | ipcMain.handle("editor-load", async (_, projectDirectory) => { 14 | const server = express(); 15 | server.use(express.static(projectDirectory)); 16 | openServer = server.listen(8080); 17 | }); 18 | 19 | ipcMain.handle("editor-unload", () => { 20 | openServer.close(); 21 | }); 22 | }; 23 | 24 | export default attatchIpcListeners; 25 | -------------------------------------------------------------------------------- /src/electron/src/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, Menu, nativeTheme } from "electron"; 2 | import menu from "./menu"; 3 | import * as path from "path"; 4 | import attatchIpcListeners from "./attachIpcListeners"; 5 | 6 | process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true"; 7 | 8 | nativeTheme.themeSource = "light"; 9 | Menu.setApplicationMenu(menu); 10 | 11 | const createWindow = (): void => { 12 | const win = new BrowserWindow({ 13 | width: 800, 14 | height: 600, 15 | fullscreen: true, 16 | webPreferences: { 17 | preload: path.join(__dirname, "./preload.js"), 18 | }, 19 | }); 20 | 21 | win.loadFile("../../app/pages/menu/index.html"); 22 | attatchIpcListeners(); 23 | }; 24 | 25 | app.once("ready", () => { 26 | createWindow(); 27 | 28 | app.on("activate", () => { 29 | if (BrowserWindow.getAllWindows().length === 0) createWindow(); 30 | }); 31 | }); 32 | 33 | app.on("window-all-closed", () => { 34 | if (process.platform !== "darwin") app.quit(); 35 | }); 36 | -------------------------------------------------------------------------------- /src/electron/src/menu.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, 3 | ipcMain, 4 | Menu, 5 | MenuItemConstructorOptions, 6 | } from "electron"; 7 | 8 | const focusedWindowSend = (channel: string) => { 9 | BrowserWindow.getFocusedWindow().webContents.send(channel); 10 | }; 11 | 12 | const menuTemplate: MenuItemConstructorOptions[] = [ 13 | { 14 | label: "Project", 15 | submenu: [ 16 | { 17 | label: "New", 18 | accelerator: "Ctrl+N", 19 | click: () => { 20 | focusedWindowSend("new-project"); 21 | }, 22 | }, 23 | { 24 | label: "Save", 25 | accelerator: "Ctrl+S", 26 | click: () => { 27 | focusedWindowSend("save"); 28 | }, 29 | }, 30 | { 31 | label: "Build", 32 | accelerator: "Ctrl+B", 33 | click: () => { 34 | focusedWindowSend("build"); 35 | }, 36 | }, 37 | { 38 | label: "Open", 39 | accelerator: "Ctrl+O", 40 | }, 41 | { 42 | label: "Open Project Directory", 43 | click: () => { 44 | focusedWindowSend("open-project-directory"); 45 | }, 46 | }, 47 | { 48 | label: "Preview Site", 49 | accelerator: "Ctrl+P", 50 | click: () => { 51 | focusedWindowSend("preview-site"); 52 | }, 53 | }, 54 | { 55 | label: "Settings", 56 | accelerator: "Shift+,", 57 | }, 58 | ], 59 | }, 60 | { 61 | label: "Developer", 62 | submenu: [ 63 | { 64 | label: "Inspect Element", 65 | role: "toggleDevTools", 66 | }, 67 | { 68 | label: "Reload Page", 69 | role: "reload", 70 | }, 71 | ], 72 | }, 73 | { 74 | label: "Opal", 75 | submenu: [ 76 | { 77 | label: "Menu", 78 | click: () => { 79 | focusedWindowSend("open-menu"); 80 | }, 81 | }, 82 | { 83 | label: "Settings", 84 | accelerator: "Ctrl+,", 85 | }, 86 | { 87 | label: "Exit", 88 | role: "close", 89 | }, 90 | ], 91 | }, 92 | ]; 93 | 94 | const menu = Menu.buildFromTemplate(menuTemplate); 95 | 96 | export default menu; 97 | -------------------------------------------------------------------------------- /src/electron/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import * as fs from "fs/promises"; 3 | import * as fsSync from "fs"; 4 | import { shell } from "electron"; 5 | 6 | contextBridge.exposeInMainWorld("ipc", { 7 | on: ipcRenderer.on.bind(ipcRenderer), 8 | invoke: ipcRenderer.invoke, 9 | postMessage: ipcRenderer.postMessage, 10 | }); 11 | 12 | contextBridge.exposeInMainWorld("fs", fs); 13 | contextBridge.exposeInMainWorld("fsSync", fsSync); 14 | contextBridge.exposeInMainWorld("shell", { 15 | openExternal: shell.openExternal, 16 | showItemInFolder: shell.showItemInFolder, 17 | }); 18 | -------------------------------------------------------------------------------- /src/opalApiTemplate.js: -------------------------------------------------------------------------------- 1 | export const CursorTypes = { 2 | POINTER: "pointer", 3 | }; 4 | 5 | export class Element { 6 | constructor(id) { 7 | this.htmlElement = document.getElementById(id); 8 | this.htmlElement.onclick = () => this.onclick(); 9 | } 10 | 11 | remove() { 12 | this.htmlElement.remove(); 13 | } 14 | 15 | show() { 16 | this.htmlElement.style.display = "block"; 17 | } 18 | 19 | hide() { 20 | this.htmlElement.style.display = "none"; 21 | } 22 | 23 | set backgroundColor(color) { 24 | this.htmlElement.style.backgroundColor = color; 25 | } 26 | 27 | set borderColor(color) { 28 | this.htmlElement.style.borderColor = color; 29 | } 30 | 31 | set borderWidth(width) { 32 | this.htmlElement.style.borderWidth = width + "px"; 33 | } 34 | 35 | set borderRadius(radius) { 36 | this.htmlElement.style.borderRadius = radius + "px"; 37 | } 38 | 39 | set cursor(type) { 40 | this.htmlElement.style.cursor = type; 41 | } 42 | 43 | onclick() {} 44 | } 45 | 46 | export class TextBoxElement extends Element { 47 | set text(value) { 48 | this.htmlElement.innerText = value; 49 | } 50 | 51 | set weight(value) { 52 | this.htmlElement.style.fontWeight = value + "px"; 53 | } 54 | 55 | set color(color) { 56 | this.htmlElement.style.color = color; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/", 4 | "target": "ES2021", 5 | "noImplicitAny": true, 6 | "allowJs": true, 7 | "module": "commonjs", 8 | "moduleResolution": "node" 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | 6 | // All pages with a source file must be added here 7 | entry: { 8 | editor: "./src/app/pages/editor/index.ts", 9 | menu: "./src/app/pages/menu/index.ts", 10 | "new-project": "./src/app/pages/new-project/index.ts", 11 | }, 12 | 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.ts$/, 17 | use: 'ts-loader', 18 | exclude: /node_modules/, 19 | }, 20 | ], 21 | }, 22 | resolve: { 23 | extensions: ['.ts', '.js'], 24 | }, 25 | output: { 26 | filename: '[name].js', 27 | path: path.resolve(__dirname, './src/app/build'), 28 | }, 29 | }; --------------------------------------------------------------------------------