├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lockb ├── images ├── demo.gif └── pantheon-hero.png ├── next.config.js ├── package.json ├── public ├── favicon.png ├── favicon.svg ├── handle.png ├── og-image.png └── pantheon.png ├── src ├── action.ts ├── alphanumeric.ts ├── app │ ├── easel │ │ ├── README.md │ │ ├── easel-in-iframe.tsx │ │ ├── layout.tsx │ │ ├── node-component.tsx │ │ ├── page-loading.tsx │ │ ├── page.tsx │ │ └── use-events-propagator.ts │ ├── global-styles.scss │ ├── ground.tsx │ ├── header │ │ └── global-header.tsx │ ├── layout.tsx │ ├── page.module.scss │ └── page.tsx ├── atoms.ts ├── command.ts ├── constants.ts ├── context-menu │ ├── context-menu-button.tsx │ ├── context-menu.module.scss │ └── context-menu.tsx ├── control-center │ ├── control-center-node.tsx │ ├── control-center.tsx │ ├── controls.tsx │ └── tsx.tsx ├── data-attributes.ts ├── drawer │ ├── drawer-item-wrapper.module.scss │ ├── drawer-item-wrapper.tsx │ ├── drawer.module.scss │ └── drawer.tsx ├── easel │ ├── easel-container.module.scss │ ├── easel-container.tsx │ ├── easel-wrapper.module.scss │ ├── easel-wrapper.tsx │ ├── page-title.module.scss │ ├── page-title.tsx │ └── resizer.tsx ├── editor-state.ts ├── empty-placeholder.tsx ├── error-boundary.tsx ├── events.ts ├── format.ts ├── global.d.ts ├── ground.ts ├── history.spec.ts ├── history.ts ├── hooks │ └── use-global-events.tsx ├── libraries │ ├── radix-themes-3.0.1 │ │ ├── components.ts │ │ ├── drawer-items.tsx │ │ └── node-definitions.ts │ └── studio-1.0.0 │ │ ├── components.tsx │ │ └── node-definitions.ts ├── library-definition.ts ├── library.ts ├── load-node-component-map.ts ├── node-class │ ├── node-util.ts │ ├── node.spec.ts │ ├── node.ts │ ├── page.tsx │ └── view.tsx ├── node-component.module.scss ├── node-definition.ts ├── node-lib.tsx ├── record.ts ├── serial.ts ├── serialize-props.ts ├── shortcuts-dialog.tsx ├── shortcuts.ts ├── studio-app.tsx ├── tree │ ├── tree.module.scss │ └── tree.tsx ├── types │ ├── extract-generic.ts │ └── guide-dimension.ts └── ui-guides │ ├── drop-zone-guide.module.scss │ ├── drop-zone-guide.tsx │ ├── hover-guide.module.scss │ ├── hover-guide.tsx │ ├── selection-guide.module.scss │ └── selection-guide.tsx └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "trailingComma": "all", 6 | "semi": false, 7 | "tabWidth": 2, 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": "explicit", 5 | "source.organizeImports": "explicit" 6 | }, 7 | "cSpell.words": [ 8 | "estree", 9 | "gapless", 10 | "highlightjs", 11 | "hljs", 12 | "minmax", 13 | "nanostores", 14 | "nums", 15 | "typecheck", 16 | "Unselectable", 17 | "Unselectables", 18 | "unwrappable", 19 | "xlarge", 20 | "xsmall" 21 | ], 22 | "typescript.tsdk": "node_modules/typescript/lib" 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jang Haemin 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Demo](images/pantheon-hero.png) 2 | 3 | # Pantheon 4 | 5 | Pantheon is an open-source WYSIWYG web application editor with real-time interaction and code generation. 6 | 7 | The main goal is to destroy the barrier between design and development by eliminating the time and effort to convert the design to code. 8 | 9 | [Radix Themes](https://www.radix-ui.com/) is the primary component library for the editor as you can see in the name. But any other component library or even custom components including the native DOM elements can be used. 10 | 11 | Try the demo at [radix-ui.studio](https://radix-ui.studio/) and see how it works. You can't save or load your work yet. 12 | 13 | > **Note**: It is still in development and not ready for production. 14 | 15 | ## Dev 16 | 17 | ```sh 18 | bun install 19 | bun run dev 20 | ``` 21 | 22 | ## Roadmap 23 | 24 | - [x] Declare drawer item with node then clone node when drag and drop to easel 25 | - [ ] Re-write UI guides in Vanilla 26 | - [ ] Routing 27 | - [ ] Connect to other pages 28 | - [x] Focus on the page on creation 29 | - [x] Handle ground overscroll 30 | - Double click page to focus 31 | - [x] Keyboard shortcuts 32 | - [x] Improve design mode 33 | - [x] Multiple pages 34 | - [x] Render multiple pages inside a single ground 35 | - [x] Click drawer item to add node to easel 36 | - [ ] Stop unnecessary css animations of components 37 | - [x] Tree view 38 | - [ ] Lock/unlock nodes 39 | - [x] Improve text-based nodes controlling experience 40 | - Introduce mass mode 41 | - [x] Seamless panning from inside iframe to outside iframe 42 | - [ ] View (Template) 43 | - [ ] Labeling 44 | - [ ] Detached 45 | - [ ] Synced 46 | - [ ] Conditional rendering 47 | - [ ] Repetitive rendering 48 | - [x] Subscription-based global node atoms list update instead of registering/deleting logic inside each function 49 | - [x] Use iframe for easel 50 | - [x] Sync drag and drop 51 | - [x] Sync events 52 | - [x] Sync mouse events 53 | - [x] Sync keyboard events 54 | - [x] Sync atoms 55 | - [x] Drag and drop nodes 56 | - [x] From drawer to easel 57 | - [x] Add to container 58 | - [x] Add to index of container 59 | - [x] Generalize implementation -> easel and easel -> easel (currently duplicate implementation) 60 | - [x] Move inside easel 61 | - [x] Move to another container 62 | - [x] Prevent moving outer container to inner container (tree violation) 63 | - [x] Improve logic. Check contains -> Just ignore pointer events on the dragging elements 64 | - [x] Move to another index of container 65 | - [x] Multi selection, dragging, editing 66 | - [x] Multi selection 67 | - [x] Drag and drop multiple nodes 68 | - [ ] Improve ghost 69 | - [x] Delete multiple nodes 70 | - [x] Multiple node editing 71 | - [x] Context menu 72 | - [x] Copy and paste nodes 73 | - [x] Copy nodes 74 | - [x] Paste nodes 75 | - [x] Paste nodes to another container 76 | - [x] Paste nodes to another index of container 77 | - [ ] Generate tsx code 78 | - [ ] Download file 79 | - [x] Download single file 80 | - [ ] zip multiple files 81 | - [x] Props 82 | - [x] Self closing tag when there is no children 83 | - [x] Generate part of code 84 | - [x] Type meta data 85 | - [x] Record components version 86 | - [x] Separate node properties to props key 87 | - [ ] ~~Use `map` or `deepMap` for node atoms~~ 88 | - [ ] ~~Declarative application generation~~ 89 | - [ ] ~~Generate Studio code itself from component language server~~ 90 | - [ ] ~~Generate type guard functions~~ 91 | - [x] Solution for handling components version up 92 | - Introduce Library 93 | - [x] Improve rendering performance on bulk node update 94 | - For example, when inserting multiple nodes, it should not re-render the easel for each node insertion. 95 | - This is not a problem for now. But it will be a problem when we have a lot of nodes. 96 | - [x] Flexible easel positioning 97 | - [x] Zoom in/out 98 | - [x] Pan 99 | - [x] Undo/Redo 100 | - [x] Action-based instead of state-based 101 | - [x] Bundle multiple actions into a single action bundle 102 | - [x] Insert 103 | - [x] Remove 104 | - [x] Move nodes (Insert) 105 | - [x] Properties 106 | - [ ] ~~Slots~~ 107 | - [x] Wrap (Insert) 108 | - [x] Unwrap (Insert + Remove) 109 | - [x] Resize pages 110 | - [x] Move pages (translate) 111 | - [x] Composition components are not containable nodes 112 | - [x] Don't add custom style or class to components. Instead use `data-*` attributes for custom styling. 113 | - [ ] ~~Pass shared data from top window to iframe through postMessage instead of window injecting.~~ 114 | - [x] Wrap common node props to a single object. (instead of rest props) 115 | - [ ] ~~Inject node attributes to the outermost element of components.~~ 116 | - [ ] State management 117 | 118 | ## License 119 | 120 | Pantheon is licensed under the [MIT License](LICENSE). 121 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/bun.lockb -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/images/demo.gif -------------------------------------------------------------------------------- /images/pantheon-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/images/pantheon-hero.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: false, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pantheon", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "test": "bun test", 7 | "test:watch": "bun test --watch", 8 | "dev": "next dev --port 5520", 9 | "build": "next build", 10 | "start": "next start", 11 | "lint": "next lint", 12 | "typecheck": "tsc --noEmit", 13 | "typecheck:watch": "tsc --noEmit --watch" 14 | }, 15 | "dependencies": { 16 | "@nanostores/react": "^0.7.2", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/themes": "^3.0.2", 19 | "@radix-ui/themes-3.0.1": "npm:@radix-ui/themes@3.0.2", 20 | "change-case": "^5.4.4", 21 | "clsx": "^2.1.0", 22 | "highlight.js": "^11.9.0", 23 | "minmax.js": "^1.0.0", 24 | "nanoid": "^5.0.6", 25 | "nanostores": "^0.10.3", 26 | "next": "14.1.4", 27 | "react": "^18", 28 | "react-dom": "^18" 29 | }, 30 | "devDependencies": { 31 | "@types/bun": "^1.0.12", 32 | "@types/highlightjs": "^9.12.6", 33 | "@types/node": "^20", 34 | "@types/react": "^18", 35 | "@types/react-dom": "^18", 36 | "eslint": "^9", 37 | "eslint-config-next": "14.1.4", 38 | "eslint-config-prettier": "^9.1.0", 39 | "prettier": "^3.2.5", 40 | "sass": "^1.74.1", 41 | "typescript": "5.4.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/public/favicon.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/handle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/public/handle.png -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/public/og-image.png -------------------------------------------------------------------------------- /public/pantheon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhaemin/Pantheon/00047c46bf9dd2cfa03c4fd6ce69cd3b1f5ca418/public/pantheon.png -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { StoreKeys } from '@nanostores/react' 2 | import { MapStore } from 'nanostores' 3 | import { $selectedNodes } from './atoms' 4 | import { Node } from './node-class/node' 5 | import { PageNode } from './node-class/page' 6 | import { studioApp } from './studio-app' 7 | 8 | export abstract class Action { 9 | abstract undo(): void 10 | abstract redo(): void 11 | } 12 | 13 | export class AddPageAction extends Action { 14 | private page: PageNode 15 | 16 | constructor({ page }: { page: PageNode }) { 17 | super() 18 | this.page = page 19 | } 20 | 21 | undo(): void { 22 | studioApp.removePage(this.page) 23 | } 24 | 25 | redo(): void { 26 | studioApp.addPage(this.page) 27 | } 28 | } 29 | 30 | export class RemovePageAction extends Action { 31 | private page: PageNode 32 | 33 | constructor({ page }: { page: PageNode }) { 34 | super() 35 | this.page = page 36 | } 37 | 38 | undo(): void { 39 | studioApp.addPage(this.page) 40 | } 41 | 42 | redo(): void { 43 | studioApp.removePage(this.page) 44 | } 45 | } 46 | 47 | /** 48 | * TODO: store multiple nodes in the action 49 | */ 50 | export class InsertNodeAction extends Action { 51 | private insertedNode: Node 52 | private oldParent: Node | null 53 | private oldNextSibling: Node | null 54 | private newParent: Node 55 | private newNextSibling: Node | null 56 | 57 | constructor({ 58 | insertedNode, 59 | oldParent, 60 | oldNextSibling, 61 | newParent, 62 | newNextSibling, 63 | }: { 64 | insertedNode: Node 65 | oldParent: Node | null 66 | oldNextSibling: Node | null 67 | newParent: Node 68 | newNextSibling: Node | null 69 | }) { 70 | super() 71 | this.insertedNode = insertedNode 72 | this.oldParent = oldParent 73 | this.oldNextSibling = oldNextSibling 74 | this.newParent = newParent 75 | this.newNextSibling = newNextSibling 76 | } 77 | 78 | undo(): void { 79 | // Move to old parent 80 | if (this.oldParent) { 81 | this.oldParent.insertBefore(this.insertedNode, this.oldNextSibling) 82 | } 83 | // Remove if old parent doesn't exist 84 | else { 85 | this.insertedNode.remove() 86 | } 87 | } 88 | 89 | redo(): void { 90 | this.newParent.insertBefore(this.insertedNode, this.newNextSibling) 91 | } 92 | } 93 | 94 | export class RemoveNodeAction extends Action { 95 | private removedNode: Node 96 | /** 97 | * If parent is null, it means the node is a page node 98 | */ 99 | private oldParent: Node | null 100 | private oldNextSibling: Node | null 101 | 102 | constructor({ 103 | removedNode, 104 | oldParent, 105 | oldNextSibling, 106 | }: { 107 | removedNode: Node 108 | oldParent: Node | null 109 | oldNextSibling: Node | null 110 | }) { 111 | super() 112 | this.removedNode = removedNode 113 | this.oldParent = oldParent 114 | this.oldNextSibling = oldNextSibling 115 | } 116 | 117 | undo(): void { 118 | if (this.oldParent) { 119 | this.oldParent.insertBefore([this.removedNode], this.oldNextSibling) 120 | } else { 121 | // Page 122 | studioApp.insertPageBefore( 123 | this.removedNode as PageNode, 124 | this.oldNextSibling as PageNode, 125 | ) 126 | } 127 | } 128 | 129 | redo(): void { 130 | this.removedNode.remove() 131 | $selectedNodes.set([]) 132 | } 133 | } 134 | 135 | export class PageResizeAction extends Action { 136 | private page: PageNode 137 | private oldSize: { width: number; height: number } 138 | private newSize: { width: number; height: number } 139 | 140 | constructor({ 141 | page, 142 | oldSize, 143 | newSize, 144 | }: { 145 | page: PageNode 146 | oldSize: { width: number; height: number } 147 | newSize: { width: number; height: number } 148 | }) { 149 | super() 150 | this.page = page 151 | this.oldSize = oldSize 152 | this.newSize = newSize 153 | } 154 | 155 | undo(): void { 156 | this.page.dimensions = this.oldSize 157 | } 158 | 159 | redo(): void { 160 | this.page.dimensions = this.newSize 161 | } 162 | } 163 | 164 | export class PageMoveAction extends Action { 165 | private pages: PageNode[] 166 | private delta: { x: number; y: number } 167 | 168 | constructor({ 169 | pages, 170 | delta, 171 | }: { 172 | pages: PageNode[] 173 | delta: { x: number; y: number } 174 | }) { 175 | super() 176 | this.pages = pages 177 | this.delta = delta 178 | } 179 | 180 | undo(): void { 181 | this.pages.forEach((page) => { 182 | page.coordinates = { 183 | x: page.coordinates.x - this.delta.x, 184 | y: page.coordinates.y - this.delta.y, 185 | } 186 | }) 187 | } 188 | 189 | redo(): void { 190 | this.pages.forEach((page) => { 191 | page.coordinates = { 192 | x: page.coordinates.x + this.delta.x, 193 | y: page.coordinates.y + this.delta.y, 194 | } 195 | }) 196 | } 197 | } 198 | 199 | export class PropChangeAction extends Action { 200 | private propMapStore: MapStore 201 | private oldProp: { key: StoreKeys; value: any } 202 | private newProp: { key: StoreKeys; value: any } 203 | 204 | constructor({ 205 | propMapStore, 206 | oldProp, 207 | newProp, 208 | }: { 209 | propMapStore: MapStore 210 | oldProp: { key: StoreKeys; value: any } 211 | newProp: { key: StoreKeys; value: any } 212 | }) { 213 | super() 214 | this.propMapStore = propMapStore 215 | this.oldProp = oldProp 216 | this.newProp = newProp 217 | } 218 | 219 | undo(): void { 220 | this.propMapStore.setKey(this.oldProp.key, this.oldProp.value) 221 | } 222 | 223 | redo(): void { 224 | this.propMapStore.setKey(this.newProp.key, this.newProp.value) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/alphanumeric.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | 3 | /** 4 | * Alphanumeric random string generator 5 | */ 6 | export const alphanumericId = customAlphabet( 7 | '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 | 5, 9 | ) 10 | -------------------------------------------------------------------------------- /src/app/easel/README.md: -------------------------------------------------------------------------------- 1 | # Easel Page 2 | 3 | Easel page renders only node components. 4 | 5 | This page is being loaded inside iframe. It communicates with the parent window(global application scope). 6 | -------------------------------------------------------------------------------- /src/app/easel/easel-in-iframe.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DESIGN_MODE_EDGE_SPACE } from '@/constants' 4 | import { ErrorBoundary } from '@/error-boundary' 5 | import { stringifyLibraryKey } from '@/library' 6 | import { useStore } from '@nanostores/react' 7 | import { map } from 'nanostores' 8 | import { useEffect, useState } from 'react' 9 | import { NodeComponent } from './node-component' 10 | import { PageLoading } from './page-loading' 11 | 12 | /** 13 | * Record or Record> 14 | */ 15 | export const $dynamicComponents = map>>({}) 16 | 17 | export function EaselInIframe() { 18 | const libraries = useStore(window.ownerApp.$libraries) 19 | const [isLibrariesLoaded, setIsLibrariesLoaded] = useState(false) 20 | 21 | useEffect(() => { 22 | const unsubscribe = window.shared.$designMode.subscribe((designMode) => { 23 | if (designMode) { 24 | document.body.style.padding = DESIGN_MODE_EDGE_SPACE + 'px' 25 | } else { 26 | document.body.style.removeProperty('padding') 27 | } 28 | }) 29 | 30 | return () => { 31 | unsubscribe() 32 | } 33 | }, []) 34 | 35 | // TODO: dynamically load libraries from outside and share between pages 36 | // Maybe there will be a warning about different React instances 37 | useEffect(() => { 38 | if ( 39 | libraries.every( 40 | ({ name, version }) => 41 | $dynamicComponents.get()[`${name}-${version}`] !== undefined, 42 | ) 43 | ) { 44 | setIsLibrariesLoaded(true) 45 | return 46 | } 47 | 48 | Promise.all( 49 | libraries.map(async (library) => { 50 | const libraryKey = stringifyLibraryKey(library) 51 | 52 | const result = await import(`@/libraries/${libraryKey}/components`) 53 | 54 | $dynamicComponents.setKey(libraryKey, result.components) 55 | }), 56 | ).then(() => { 57 | setIsLibrariesLoaded(true) 58 | }) 59 | }, [libraries]) 60 | 61 | if (!isLibrariesLoaded) { 62 | return 63 | } 64 | 65 | return ( 66 | 67 | 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/app/easel/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function EaselLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode 5 | }) { 6 | return ( 7 | 8 | 14 | {children} 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/easel/node-component.tsx: -------------------------------------------------------------------------------- 1 | import { stringifyLibraryKey } from '@/library' 2 | import { useStore } from '@nanostores/react' 3 | import { batched } from 'nanostores' 4 | import { ReactNode, createElement, useEffect, useState } from 'react' 5 | import { DESIGN_MODE_EDGE_SPACE } from '../../constants' 6 | import { makeNodeAttrs } from '../../data-attributes' 7 | import { EmptyPlaceholder } from '../../empty-placeholder' 8 | import { Node } from '../../node-class/node' 9 | import { $dynamicComponents } from './easel-in-iframe' 10 | 11 | function getNodeComponent(node: Node) { 12 | const { library } = node 13 | const libraryKey = stringifyLibraryKey(library) 14 | const dynamicComponents = $dynamicComponents.get() 15 | const definition = node.definition 16 | const mod = definition.mod ?? definition.nodeName 17 | const Component = definition.sub 18 | ? dynamicComponents[libraryKey]?.[mod]?.[definition.sub] 19 | : dynamicComponents[libraryKey]?.[mod] 20 | 21 | return Component 22 | } 23 | 24 | function getChildren(node: Node, children: Node[]) { 25 | if (node.definition.leaf) { 26 | return undefined 27 | } 28 | 29 | if ( 30 | node.nodeName !== 'Page' && 31 | children.length === 0 && 32 | node.definition.gapless 33 | ) { 34 | const isDirect = 35 | node.definition.directChild || 36 | node.parent?.definition.allChildrenDirect || 37 | false 38 | 39 | // Inject node attributes to direct children because they are not wrapped with a display: contents div 40 | const attributes = isDirect ? makeNodeAttrs(node) : {} 41 | return 42 | } else if (children.length === 0) { 43 | return undefined 44 | } 45 | 46 | return renderChildren(children) 47 | } 48 | 49 | function makeNodeProps( 50 | node: Node, 51 | props: Record, 52 | style: Record, 53 | designMode: boolean, 54 | ) { 55 | return { 56 | key: node.id, 57 | ...makeNodeAttrs(node), 58 | ...props, 59 | style: { 60 | ...style, 61 | paddingTop: 62 | node.definition.gapless && designMode 63 | ? DESIGN_MODE_EDGE_SPACE 64 | : style.paddingTop, 65 | boxShadow: 66 | node.definition.gapless && designMode 67 | ? '0 0 0 1px #ddd' 68 | : style.boxShadow, 69 | backgroundImage: 70 | node.definition.gapless && designMode 71 | ? 'url(/handle.png)' 72 | : style.backgroundImage, 73 | backgroundRepeat: 'no-repeat', 74 | backgroundPosition: '50% 0', 75 | backgroundSize: '16px 12px', 76 | }, 77 | } 78 | } 79 | 80 | export function NodeComponent({ node }: { node: Node }) { 81 | const { nodeName, library } = node 82 | const libraryKey = `${library.name}-${library.version}` 83 | const definition = node.definition 84 | const Component = getNodeComponent(node) 85 | const designMode = useStore(window.shared.$designMode) 86 | const massMode = useStore(window.shared.$massMode) 87 | 88 | const children = useStore(node.$children) 89 | const props = useStore(node.$props) 90 | const style = useStore(node.$style) 91 | 92 | const [_, setForceUpdate] = useState(false) 93 | 94 | /** 95 | * Since the direct children are rendered inside the parent node directly without a wrapper, 96 | * useStore() cannot be used to listen to the changes of the direct children. 97 | * So, we need to listen to the changes by using batched() and listen(), 98 | * then manually re-render the parent. 99 | */ 100 | useEffect(() => { 101 | const directChildren = node.definition.allChildrenDirect 102 | ? children 103 | : children.filter((child) => child.definition.directChild) 104 | 105 | const $batch = batched( 106 | [ 107 | ...directChildren.map((child) => child.$children), 108 | ...directChildren.map((child) => child.$props), 109 | ...directChildren.map((child) => child.$style), 110 | ], 111 | () => { 112 | setForceUpdate((prev) => !prev) 113 | }, 114 | ) 115 | 116 | const unsubscribe = $batch.listen(() => {}) 117 | 118 | return () => { 119 | unsubscribe() 120 | } 121 | }, [node, children]) 122 | 123 | /** 124 | * Note that PageNode cannot be unmounted because once it is deleted, 125 | * its iframe is immediately removed from the DOM. 126 | * Which means there is no chance for the PageNode to be unmounted. 127 | */ 128 | useEffect(() => { 129 | node.executeOnMountCallbacks() 130 | }, [node]) 131 | 132 | if (!Component || !definition) { 133 | return ( 134 |
135 | Unsupported node: {nodeName} from {libraryKey} 136 |
137 | ) 138 | } 139 | 140 | // return 141 | // return createElement( 142 | // Component, 143 | // makeNodeProps(node, props, style, designMode), 144 | // getChildren(node, children), 145 | // ) 146 | 147 | return createElement( 148 | 'span', 149 | { 150 | style: { 151 | display: 'contents', 152 | pointerEvents: 153 | massMode && (node.definition.sub || node.nodeName === 'Text') 154 | ? 'none' 155 | : undefined, 156 | }, 157 | ...makeNodeAttrs(node), 158 | }, 159 | createElement( 160 | Component, 161 | makeNodeProps(node, props, style, designMode), 162 | getChildren(node, children), 163 | ), 164 | ) 165 | } 166 | 167 | export function renderChildren(children: Node[]): ReactNode { 168 | const reactNode = children.map((child) => { 169 | if ( 170 | child.definition.directChild || 171 | child.parent?.definition.allChildrenDirect 172 | ) { 173 | const designMode = window.shared.$designMode.get() 174 | const style = child.$style.get() 175 | const Component = getNodeComponent(child) 176 | 177 | return createElement( 178 | Component, 179 | makeNodeProps(child, child.$props.get(), style, designMode), 180 | getChildren(child, child.$children.get()), 181 | ) 182 | } 183 | 184 | // TODO: need more investigation where this display: contents wrapping makes an error 185 | // Node.element is related. 186 | // node-lib.tsx closestNode.element?.parentElement is related. 187 | return 188 | }) 189 | 190 | if (reactNode.length === 1) { 191 | return reactNode[0] 192 | } 193 | 194 | return reactNode 195 | } 196 | -------------------------------------------------------------------------------- /src/app/easel/page-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@radix-ui/themes' 2 | 3 | export function PageLoading() { 4 | return ( 5 |
17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/easel/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Theme } from '@radix-ui/themes' 4 | import dynamic from 'next/dynamic' 5 | import { PageLoading } from './page-loading' 6 | 7 | const EaselInIframe = dynamic( 8 | () => import('./easel-in-iframe').then((mod) => mod.EaselInIframe), 9 | { 10 | ssr: false, 11 | loading: () => , 12 | }, 13 | ) 14 | 15 | export default function EaselPage() { 16 | return ( 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/easel/use-events-propagator.ts: -------------------------------------------------------------------------------- 1 | // import { shared } from '@/shared' 2 | // import { useEffect } from 'react' 3 | 4 | // export function useEventsPropagator() { 5 | // useEffect(() => { 6 | // const parentFrame = window.parentFrame 7 | 8 | // if (!parentFrame) { 9 | // return 10 | // } 11 | 12 | // const onKeyDown = (e: KeyboardEvent) => { 13 | // e.preventDefault() 14 | 15 | // parentFrame.dispatchEvent(new KeyboardEvent('keydown', e)) 16 | // } 17 | 18 | // const onMouseDown = (e: MouseEvent) => { 19 | // e.preventDefault() 20 | 21 | // const frameRect = parentFrame.getBoundingClientRect() 22 | 23 | // const event = new MouseEvent('mousedown', e) 24 | // Object.defineProperty(event, 'target', { 25 | // value: e.target, 26 | // enumerable: true, 27 | // }) 28 | // Object.defineProperty(event, 'clientX', { 29 | // value: e.clientX * shared.$scale.get() + frameRect.left, 30 | // enumerable: true, 31 | // }) 32 | // Object.defineProperty(event, 'clientY', { 33 | // value: e.clientY * shared.$scale.get() + frameRect.top, 34 | // enumerable: true, 35 | // }) 36 | 37 | // parentFrame.dispatchEvent(event) 38 | // } 39 | 40 | // const onMouseMove = (e: MouseEvent) => { 41 | // e.preventDefault() 42 | 43 | // const frameRect = parentFrame.getBoundingClientRect() 44 | 45 | // const event = new MouseEvent('mousemove', e) 46 | // Object.defineProperty(event, 'target', { 47 | // value: e.target, 48 | // enumerable: true, 49 | // }) 50 | // Object.defineProperty(event, 'clientX', { 51 | // value: e.clientX * shared.$scale.get() + frameRect.left, 52 | // enumerable: true, 53 | // }) 54 | // Object.defineProperty(event, 'clientY', { 55 | // value: e.clientY * shared.$scale.get() + frameRect.top, 56 | // enumerable: true, 57 | // }) 58 | 59 | // parentFrame.dispatchEvent(event) 60 | // } 61 | 62 | // const onMouseUp = (e: MouseEvent) => { 63 | // e.preventDefault() 64 | 65 | // const frameRect = parentFrame.getBoundingClientRect() 66 | 67 | // const event = new MouseEvent('mouseup', e) 68 | // Object.defineProperty(event, 'target', { 69 | // value: e.target, 70 | // enumerable: true, 71 | // }) 72 | // Object.defineProperty(event, 'clientX', { 73 | // value: e.clientX * shared.$scale.get() + frameRect.left, 74 | // enumerable: true, 75 | // }) 76 | // Object.defineProperty(event, 'clientY', { 77 | // value: e.clientY * shared.$scale.get() + frameRect.top, 78 | // enumerable: true, 79 | // }) 80 | 81 | // parentFrame.dispatchEvent(event) 82 | // } 83 | 84 | // const onMouseOver = (e: MouseEvent) => { 85 | // e.preventDefault() 86 | 87 | // const frameRect = parentFrame.getBoundingClientRect() 88 | 89 | // const event = new MouseEvent('mouseover', e) 90 | // Object.defineProperty(event, 'target', { 91 | // value: e.target, 92 | // enumerable: true, 93 | // }) 94 | // Object.defineProperty(event, 'clientX', { 95 | // value: e.clientX * shared.$scale.get() + frameRect.left, 96 | // enumerable: true, 97 | // }) 98 | // Object.defineProperty(event, 'clientY', { 99 | // value: e.clientY * shared.$scale.get() + frameRect.top, 100 | // enumerable: true, 101 | // }) 102 | 103 | // parentFrame.dispatchEvent(event) 104 | // } 105 | 106 | // const contextMenu = (e: MouseEvent) => { 107 | // e.preventDefault() 108 | 109 | // const frameRect = parentFrame.getBoundingClientRect() 110 | 111 | // const event = new MouseEvent('contextmenu', e) 112 | 113 | // Object.defineProperty(event, 'target', { 114 | // value: e.target, 115 | // enumerable: true, 116 | // }) 117 | // Object.defineProperty(event, 'clientX', { 118 | // value: e.clientX * shared.$scale.get() + frameRect.left, 119 | // enumerable: true, 120 | // }) 121 | // Object.defineProperty(event, 'clientY', { 122 | // value: e.clientY * shared.$scale.get() + frameRect.top, 123 | // enumerable: true, 124 | // }) 125 | 126 | // parentFrame.dispatchEvent(event) 127 | // } 128 | 129 | // const onWheel = (e: WheelEvent) => { 130 | // if (!e.altKey) { 131 | // e.preventDefault() 132 | // } 133 | 134 | // const frameRect = parentFrame.getBoundingClientRect() 135 | 136 | // const event = new WheelEvent('wheel', e) 137 | // Object.defineProperty(event, 'target', { 138 | // value: e.target, 139 | // enumerable: true, 140 | // }) 141 | // Object.defineProperty(event, 'clientX', { 142 | // value: e.clientX * shared.$scale.get() + frameRect.left, 143 | // enumerable: true, 144 | // }) 145 | // Object.defineProperty(event, 'clientY', { 146 | // value: e.clientY * shared.$scale.get() + frameRect.top, 147 | // enumerable: true, 148 | // }) 149 | 150 | // parentFrame.dispatchEvent(event) 151 | // } 152 | 153 | // window.addEventListener('keydown', onKeyDown) 154 | 155 | // window.addEventListener('mousedown', onMouseDown) 156 | // window.addEventListener('mouseup', onMouseUp) 157 | // window.addEventListener('mousemove', onMouseMove) 158 | // window.addEventListener('mouseover', onMouseOver) 159 | 160 | // window.addEventListener('contextmenu', contextMenu) 161 | 162 | // window.addEventListener('wheel', onWheel, { passive: false }) 163 | 164 | // return () => { 165 | // window.removeEventListener('keydown', onKeyDown) 166 | 167 | // window.removeEventListener('mouseup', onMouseUp) 168 | // window.removeEventListener('mousedown', onMouseDown) 169 | // window.removeEventListener('mousemove', onMouseMove) 170 | // window.removeEventListener('mouseover', onMouseOver) 171 | 172 | // window.removeEventListener('contextmenu', contextMenu) 173 | 174 | // window.removeEventListener('wheel', onWheel) 175 | // } 176 | // }, []) 177 | // } 178 | -------------------------------------------------------------------------------- /src/app/global-styles.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | overscroll-behavior-x: none; 4 | } 5 | 6 | body { 7 | font-family: 8 | -apple-system, 9 | BlinkMacSystemFont, 10 | Segoe UI, 11 | Roboto, 12 | Oxygen, 13 | Ubuntu, 14 | Cantarell, 15 | Fira Sans, 16 | Droid Sans, 17 | Helvetica Neue, 18 | sans-serif !important; 19 | 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | 23 | user-select: none; 24 | } 25 | 26 | code { 27 | user-select: text; 28 | } 29 | 30 | * { 31 | margin: 0; 32 | padding: 0; 33 | 34 | appearance: none; 35 | border: none; 36 | background: none; 37 | 38 | box-sizing: border-box; 39 | 40 | outline: none; 41 | } 42 | 43 | .resizing-drawer { 44 | cursor: ns-resize; 45 | } 46 | -------------------------------------------------------------------------------- /src/app/ground.tsx: -------------------------------------------------------------------------------- 1 | import { keepNodeSelectionAttribute } from '@/data-attributes' 2 | import { EditorState } from '@/editor-state' 3 | import { Ground } from '@/ground' 4 | import { History } from '@/history' 5 | import { useStore } from '@nanostores/react' 6 | import { 7 | ArrowLeftIcon, 8 | ArrowRightIcon, 9 | CubeIcon, 10 | MagnifyingGlassIcon, 11 | } from '@radix-ui/react-icons' 12 | import { Button, Flex, IconButton, Tooltip } from '@radix-ui/themes' 13 | import dynamic from 'next/dynamic' 14 | import { useEffect, useRef } from 'react' 15 | import { $interactiveMode, $isAnimatingGround } from '../atoms' 16 | import { EaselContainer } from '../easel/easel-container' 17 | 18 | const SelectionGuide = dynamic( 19 | () => import('@/ui-guides/selection-guide').then((mod) => mod.SelectionGuide), 20 | { ssr: false }, 21 | ) 22 | 23 | const HoverGuide = dynamic( 24 | () => import('@/ui-guides/hover-guide').then((mod) => mod.HoverGuide), 25 | { ssr: false }, 26 | ) 27 | 28 | export const GROUND_ID = 'studio-ground' 29 | 30 | const SCALE_FACTOR = 0.004 31 | 32 | export function GroundComponent() { 33 | const ref = useRef(null!) 34 | const interactiveMode = useStore($interactiveMode) 35 | 36 | useEffect(() => { 37 | const groundElm = document.getElementById(GROUND_ID)! 38 | 39 | // Attach wheel event from useEffect since it's not possible to preventDefault on React wheel event 40 | function onWheel(e: WheelEvent) { 41 | e.preventDefault() 42 | 43 | if ($isAnimatingGround.get()) return 44 | 45 | // Zoom in/out 46 | if (e.metaKey || e.ctrlKey) { 47 | Ground.setScale(Ground.scale + e.deltaY * -SCALE_FACTOR, { 48 | x: e.clientX, 49 | y: e.clientY, 50 | }) 51 | } 52 | // Scroll 53 | else { 54 | const { deltaX, deltaY } = e 55 | 56 | const { x: translateX, y: translateY } = Ground.translate 57 | 58 | Ground.setTranslate(translateX - deltaX, translateY - deltaY) 59 | } 60 | } 61 | 62 | groundElm.addEventListener('wheel', onWheel) 63 | 64 | return () => { 65 | groundElm.removeEventListener('wheel', onWheel) 66 | } 67 | }, []) 68 | 69 | return ( 70 |
84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
92 | ) 93 | } 94 | 95 | function ScaleBadge() { 96 | const scale = useStore(Ground.$scale) 97 | 98 | return ( 99 | 109 |
110 | 131 |
132 |
133 | ) 134 | } 135 | 136 | function OpenDrawerButton() { 137 | return ( 138 | 139 | { 147 | EditorState.$drawerOpen.set(true) 148 | }} 149 | > 150 | 151 | 152 | 153 | ) 154 | } 155 | 156 | function UndoRedo() { 157 | const historyPointer = useStore(History.$historyPointer) 158 | const historyStack = useStore(History.$historyStack) 159 | 160 | const canUndo = historyPointer >= 0 161 | const canRedo = historyPointer < historyStack.length - 1 162 | 163 | return ( 164 | 175 | { 179 | History.undo() 180 | }} 181 | > 182 | 183 | 184 | 185 | { 189 | History.redo() 190 | }} 191 | > 192 | 193 | 194 | 195 | ) 196 | } 197 | -------------------------------------------------------------------------------- /src/app/header/global-header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | $designMode, 3 | $interactiveMode, 4 | $massMode, 5 | $shortcutsDialogOpen, 6 | } from '@/atoms' 7 | import { Command } from '@/command' 8 | import { useStore } from '@nanostores/react' 9 | import { FilePlusIcon, GitHubLogoIcon } from '@radix-ui/react-icons' 10 | import { 11 | Badge, 12 | Button, 13 | Flex, 14 | Heading, 15 | IconButton, 16 | Kbd, 17 | Separator, 18 | Switch, 19 | Text, 20 | } from '@radix-ui/themes' 21 | 22 | export function GlobalHeader() { 23 | const interactiveMode = useStore($interactiveMode) 24 | const designMode = useStore($designMode) 25 | const massMode = useStore($massMode) 26 | 27 | return ( 28 | 39 | 40 | 41 | Pantheon 42 | Pantheon 43 | 44 | alpha 45 | 46 | 47 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Mass mode 62 | { 66 | $massMode.set(checked) 67 | }} 68 | /> 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | Interactive mode 77 | { 81 | $interactiveMode.set(checked) 82 | }} 83 | /> 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Design mode 92 | { 96 | $designMode.set(checked) 97 | }} 98 | /> 99 | 100 | 101 | 102 | 103 | 104 | 116 | 117 | 118 | 119 | { 123 | window.open('https://github.com/jhaemin/pantheon', '_blank') 124 | }} 125 | > 126 | 127 | 128 | 129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@radix-ui/themes/styles.css' 2 | import type { Metadata } from 'next' 3 | import './global-styles.scss' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Pantheon', 7 | description: 'A WYSIWYG web design editor', 8 | openGraph: { 9 | type: 'website', 10 | locale: 'en', 11 | siteName: 'Pantheon', 12 | title: 'Pantheon', 13 | description: 'A WYSIWYG web design editor', 14 | images: [ 15 | { 16 | url: 'https://radix-ui.studio/og-image.png', 17 | width: 1200, 18 | height: 630, 19 | alt: 'Pantheon', 20 | }, 21 | ], 22 | }, 23 | icons: [ 24 | { 25 | rel: 'icon', 26 | url: '/favicon.png', 27 | }, 28 | { 29 | rel: 'icon', 30 | url: '/favicon.svg', 31 | }, 32 | { 33 | media: '(prefers-color-scheme: light)', 34 | rel: 'icon', 35 | url: '/favicon.svg', 36 | }, 37 | { 38 | media: '(prefers-color-scheme: dark)', 39 | rel: 'icon', 40 | url: '/favicon.svg', 41 | }, 42 | ], 43 | } 44 | 45 | export default function RootLayout({ 46 | children, 47 | }: { 48 | children: React.ReactNode 49 | }) { 50 | return ( 51 | 52 | {children} 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/app/page.module.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .content { 13 | position: relative; 14 | flex: 1; 15 | 16 | .rightPanel { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | width: 280px; 22 | background-color: var(--color-background); 23 | z-index: 100; 24 | border-left: 1px solid var(--gray-a6); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { GroundComponent } from '@/app/ground' 4 | import { GlobalHeader } from '@/app/header/global-header' 5 | import { ControlCenter } from '@/control-center/control-center' 6 | import { Drawer } from '@/drawer/drawer' 7 | import { useGlobalEvents } from '@/hooks/use-global-events' 8 | import { ShortcutsDialog } from '@/shortcuts-dialog' 9 | import { studioApp } from '@/studio-app' 10 | import { Tree } from '@/tree/tree' 11 | import { DropZoneGuide } from '@/ui-guides/drop-zone-guide' 12 | import { useStore } from '@nanostores/react' 13 | import { Theme } from '@radix-ui/themes' 14 | import 'highlight.js/styles/github.css' 15 | import { useEffect } from 'react' 16 | import styles from './page.module.scss' 17 | 18 | export default function Studio() { 19 | useGlobalEvents() 20 | const isReady = useStore(studioApp.$isReady) 21 | 22 | useEffect(() => { 23 | studioApp.initialize({ 24 | appTitle: 'Studio App', 25 | libraries: [ 26 | { 27 | name: 'studio', 28 | version: '1.0.0', 29 | }, 30 | { 31 | name: 'radix-themes', 32 | version: '3.0.1', 33 | }, 34 | ], 35 | studioVersion: '1.0.0', 36 | }) 37 | }, []) 38 | 39 | if (!isReady) { 40 | return null 41 | } 42 | 43 | return ( 44 | 45 |
46 | 47 | 48 |
49 | 55 | 56 | {/* */} 57 | 58 |
59 | 60 | 61 |
62 |
63 | 64 | 65 | 66 | {/* */} 67 | 68 | 69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom, map } from 'nanostores' 2 | import { FC } from 'react' 3 | import { Node } from './node-class/node' 4 | import { NodeDefinition } from './node-definition' 5 | 6 | export const $devToolsRerenderFlag = atom(false) 7 | export const $selectionRerenderFlag = atom(false) 8 | export const $hoverRerenderFlag = atom(false) 9 | 10 | export function triggerRerenderGuides(lazy = false) { 11 | if (lazy) { 12 | process.nextTick(() => { 13 | $hoverRerenderFlag.set(!$hoverRerenderFlag.get()) 14 | $selectionRerenderFlag.set(!$selectionRerenderFlag.get()) 15 | }) 16 | } else { 17 | $hoverRerenderFlag.set(!$hoverRerenderFlag.get()) 18 | $selectionRerenderFlag.set(!$selectionRerenderFlag.get()) 19 | } 20 | } 21 | 22 | export const $showDevTools = atom(false) 23 | 24 | export const $isContextMenuOpen = atom(false) 25 | 26 | export const $hoveredNode = atom(null) 27 | export const $selectedNodes = atom([]) 28 | 29 | export const $isDraggingNode = atom(false) 30 | 31 | $isDraggingNode.listen((isDraggingNode) => { 32 | if (isDraggingNode) { 33 | document.body.classList.add('dragging-node') 34 | } else { 35 | document.body.classList.remove('dragging-node') 36 | } 37 | }) 38 | 39 | export const $isAnimatingGround = atom(false) 40 | 41 | export const $isResizingIframe = atom(false) 42 | 43 | export const $dropZone = atom<{ 44 | dropZoneElm: Element 45 | targetNode: Node 46 | droppingNodes: Node[] 47 | before?: string 48 | } | null>(null) 49 | 50 | export const $interactiveMode = atom(false) 51 | 52 | export const $designMode = atom(false) 53 | 54 | $designMode.listen(() => { 55 | process.nextTick(() => { 56 | triggerRerenderGuides() 57 | }) 58 | }) 59 | 60 | $interactiveMode.listen((interactiveMode) => { 61 | if (interactiveMode) { 62 | $hoveredNode.set(null) 63 | $selectedNodes.set([]) 64 | $dropZone.set(null) 65 | } 66 | }) 67 | 68 | export const $massMode = atom(false) 69 | 70 | export const $dynamicLibrary = map< 71 | Record< 72 | string, 73 | { 74 | nodeDefinitions: Record 75 | components: Record 76 | } 77 | > 78 | >({}) 79 | 80 | /** 81 | * Whether the shortcuts dialog is open. 82 | */ 83 | export const $shortcutsDialogOpen = atom(false) 84 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | AddPageAction, 4 | InsertNodeAction, 5 | RemoveNodeAction, 6 | RemovePageAction, 7 | } from './action' 8 | import { $hoveredNode, $selectedNodes } from './atoms' 9 | import { Ground } from './ground' 10 | import { History, HistoryStackItem } from './history' 11 | import { Node, SerializedNode } from './node-class/node' 12 | import { NodeUtil } from './node-class/node-util' 13 | import { PageNode } from './node-class/page' 14 | import { isUnwrappableNode } from './node-lib' 15 | import { studioApp } from './studio-app' 16 | 17 | export function commandInsertNodes( 18 | parent: Node, 19 | nodes: Node[], 20 | before: Node | null, 21 | ) { 22 | const removableNodes = nodes.filter( 23 | (node) => node.isRemovable && node !== parent, 24 | ) 25 | 26 | const actions = removableNodes.map((node) => { 27 | return new InsertNodeAction({ 28 | insertedNode: node, 29 | oldParent: node.parent, 30 | oldNextSibling: node.nextSibling, 31 | newParent: parent, 32 | newNextSibling: before, 33 | }) 34 | }) 35 | 36 | parent.insertBefore(nodes, before) 37 | 38 | History.push({ 39 | actions, 40 | previousSelectedNodes: $selectedNodes.get(), 41 | nextSelectedNodes: nodes, 42 | }) 43 | 44 | process.nextTick(() => { 45 | $selectedNodes.set(nodes) 46 | }) 47 | } 48 | 49 | export function commandRemoveNodes(nodes: Node[]) { 50 | const removableNodes = nodes.filter((node) => node.isRemovable) 51 | 52 | const actions = removableNodes.map((node) => { 53 | const action = (() => { 54 | return new RemoveNodeAction({ 55 | removedNode: node, 56 | oldParent: node.parent, 57 | oldNextSibling: node.nextSibling, 58 | }) 59 | })() 60 | 61 | node.remove() 62 | 63 | return action 64 | }) 65 | 66 | const nextSelectedNodes: Node[] = [] 67 | 68 | const historyItem: HistoryStackItem = { 69 | actions, 70 | previousSelectedNodes: [...$selectedNodes.get()], 71 | nextSelectedNodes, 72 | } 73 | 74 | // If nothing to delete, ignore. 75 | if (actions.length === 0) return 76 | 77 | History.push(historyItem) 78 | 79 | $selectedNodes.set(nextSelectedNodes) 80 | $hoveredNode.set(null) 81 | } 82 | 83 | // export function commandWrapNodes( 84 | // nodes: Node[], 85 | // wrappingNodeName: NodeName, 86 | // ): Node | undefined { 87 | // const removableNodes = nodes.filter((node) => node.isRemovable) 88 | // if (removableNodes.length === 0) return 89 | 90 | // const firstSelectedNode = $selectedNodes.get()[0] 91 | 92 | // // If nothing selected, ignore. 93 | // if (!firstSelectedNode) { 94 | // return 95 | // } 96 | 97 | // const firstNode = removableNodes[0] 98 | 99 | // if (!firstNode) return 100 | 101 | // const parent = firstNode.parent 102 | 103 | // if (!parent || !(parent instanceof Node)) return 104 | 105 | // let nextSibling = firstNode.nextSibling 106 | 107 | // let loopCount = 0 108 | 109 | // while ( 110 | // removableNodes.find((node) => node.id === nextSibling?.id) !== undefined 111 | // ) { 112 | // if (nextSibling) { 113 | // nextSibling = nextSibling.nextSibling 114 | // } 115 | 116 | // loopCount++ 117 | 118 | // if (loopCount > 1000) { 119 | // throw new Error('Infinite loop') 120 | // } 121 | // } 122 | 123 | // const actions: Action[] = [] 124 | 125 | // // TODO: This is a hack to make TypeScript happy 126 | // const newWrapperNode = new nodeMap[wrappingNodeName as NativeNodeName]() 127 | 128 | // removableNodes.forEach((node) => { 129 | // if (node.slotKey) { 130 | // if (!node.parent) { 131 | // throw new Error('Node has no parent') 132 | // } 133 | 134 | // actions.push( 135 | // new DisableSlotAction({ 136 | // slot: node, 137 | // slotParent: node.parent, 138 | // }), 139 | // ) 140 | // } else { 141 | // actions.push( 142 | // new RemoveNodeAction({ 143 | // removedNode: node, 144 | // oldParent: node.parent, 145 | // oldNextSibling: node.nextSibling, 146 | // }), 147 | // ) 148 | // } 149 | // }) 150 | 151 | // newWrapperNode.append(...removableNodes) 152 | 153 | // actions.push( 154 | // new InsertNodeAction({ 155 | // insertedNode: newWrapperNode, 156 | // oldParent: newWrapperNode.parent, 157 | // oldNextSibling: newWrapperNode.nextSibling, 158 | // newParent: parent, 159 | // newNextSibling: nextSibling, 160 | // }), 161 | // ) 162 | 163 | // parent.insertBefore(newWrapperNode, nextSibling) 164 | 165 | // History.push({ 166 | // actions, 167 | // previousSelectedNodes: $selectedNodes.get(), 168 | // nextSelectedNodes: [newWrapperNode], 169 | // }) 170 | 171 | // setTimeout(() => { 172 | // $selectedNodes.set([newWrapperNode]) 173 | // }, 0) 174 | 175 | // return newWrapperNode as Node 176 | // } 177 | 178 | export function commandUnwrapNode(unwrappingNode: Node) { 179 | if (!isUnwrappableNode(unwrappingNode)) return 180 | 181 | const nextSibling = unwrappingNode.nextSibling 182 | const parent = unwrappingNode.parent 183 | 184 | if (!(parent instanceof Node)) return 185 | 186 | const actions: Action[] = [] 187 | 188 | actions.push( 189 | new RemoveNodeAction({ 190 | removedNode: unwrappingNode, 191 | oldParent: unwrappingNode.parent, 192 | oldNextSibling: unwrappingNode.nextSibling, 193 | }), 194 | ) 195 | 196 | // First remove the unwrapping node from its parent 197 | parent.removeChild(unwrappingNode) 198 | 199 | unwrappingNode.children.forEach((child) => { 200 | actions.push( 201 | new InsertNodeAction({ 202 | insertedNode: child, 203 | oldParent: child.parent, 204 | oldNextSibling: child.nextSibling, 205 | newParent: parent, 206 | newNextSibling: nextSibling, 207 | }), 208 | ) 209 | }) 210 | 211 | parent.insertBefore(unwrappingNode.children, nextSibling) 212 | } 213 | 214 | export function commandFocusPage(page: PageNode, animation = false) { 215 | Ground.focus(page, animation) 216 | } 217 | 218 | export function commandRemovePage(page: PageNode) { 219 | History.push({ 220 | actions: [new RemovePageAction({ page })], 221 | previousSelectedNodes: $selectedNodes.get(), 222 | nextSelectedNodes: [], 223 | }) 224 | 225 | studioApp.removePage(page) 226 | $selectedNodes.set([]) 227 | } 228 | 229 | export namespace Command { 230 | export function addPage() { 231 | const newPage = new PageNode({ 232 | library: { name: 'studio', version: '1.0.0' }, 233 | nodeName: 'Page', 234 | }) 235 | 236 | History.push({ 237 | actions: [new AddPageAction({ page: newPage })], 238 | previousSelectedNodes: $selectedNodes.get(), 239 | nextSelectedNodes: [newPage], 240 | }) 241 | 242 | const maxX = Math.max( 243 | 0, 244 | ...studioApp.pages.map( 245 | ({ coordinates: { x }, dimensions: { width } }) => x + width, 246 | ), 247 | ) 248 | 249 | const minY = Math.min( 250 | 0, 251 | ...studioApp.pages.map( 252 | ({ coordinates: { y }, dimensions: { height } }) => y + height, 253 | ), 254 | ) 255 | 256 | newPage.coordinates = { 257 | x: maxX + 100, 258 | y: minY + 100, 259 | } 260 | 261 | studioApp.addPage(newPage) 262 | 263 | const unsubscribe = newPage.onIframeMount(() => { 264 | unsubscribe() 265 | 266 | $selectedNodes.set([newPage]) 267 | Ground.focus(newPage, studioApp.pages.length > 1) 268 | }) 269 | } 270 | 271 | export function copyNodes() { 272 | const selectedNodes = $selectedNodes.get() 273 | 274 | if (selectedNodes.length === 0) return 275 | 276 | const copiedNodes = selectedNodes.map((node) => node.clone()) 277 | 278 | localStorage.setItem( 279 | 'copiedNodes', 280 | JSON.stringify(copiedNodes.map((node) => node.serialize())), 281 | ) 282 | } 283 | 284 | export function pasteNodes() { 285 | const copiedNodes = JSON.parse( 286 | localStorage.getItem('copiedNodes') || '[]', 287 | ) as SerializedNode[] 288 | 289 | if (copiedNodes.length === 0) return 290 | 291 | const pastedNodes = copiedNodes.map((serializedNode) => { 292 | const node = NodeUtil.deserialize(serializedNode, true) 293 | return node 294 | }) 295 | 296 | const selectedNodes = $selectedNodes.get() 297 | const lastSelectedNode = selectedNodes[selectedNodes.length - 1] 298 | 299 | if (!lastSelectedNode) { 300 | return 301 | } 302 | 303 | const pageNodes = pastedNodes.filter( 304 | (node) => node instanceof PageNode, 305 | ) as PageNode[] 306 | const otherNodes = pastedNodes.filter((node) => !(node instanceof PageNode)) 307 | 308 | pageNodes.forEach((pageNode) => { 309 | studioApp.addPage(pageNode) 310 | }) 311 | 312 | const parent = lastSelectedNode.parent 313 | 314 | if (!parent) { 315 | commandInsertNodes(lastSelectedNode, otherNodes, null) 316 | } else { 317 | commandInsertNodes(parent, otherNodes, lastSelectedNode.nextSibling) 318 | } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DESIGN_MODE_EDGE_SPACE = 12 2 | 3 | /** 4 | * Mousemove distance threshold to start dragging 5 | * 6 | * Especially for distinguishing between dragging and clicking 7 | */ 8 | export const DRAG_THRESHOLD = 4 9 | -------------------------------------------------------------------------------- /src/context-menu/context-menu-button.tsx: -------------------------------------------------------------------------------- 1 | import { $isContextMenuOpen, triggerRerenderGuides } from '@/atoms' 2 | import { Button, Flex, Text } from '@radix-ui/themes' 3 | import { ComponentProps } from 'react' 4 | 5 | export type ContextMenuButtonProps = { 6 | label: string 7 | color?: ComponentProps['color'] 8 | onClick: () => void 9 | icon: React.FC 10 | shortcut?: string 11 | } 12 | 13 | export function ContextMenuButton({ 14 | label, 15 | onClick, 16 | color, 17 | icon, 18 | shortcut, 19 | }: ContextMenuButtonProps) { 20 | const Icon = icon as React.FC<{ size: number; color: string }> 21 | 22 | return ( 23 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/context-menu/context-menu.module.scss: -------------------------------------------------------------------------------- 1 | .contextMenuOverlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | 6 | width: 100%; 7 | height: 100%; 8 | 9 | pointer-events: none; 10 | 11 | z-index: 9999; 12 | 13 | &.open { 14 | pointer-events: auto; 15 | } 16 | } 17 | 18 | .contextMenu { 19 | position: fixed; 20 | z-index: 10000; 21 | 22 | background-color: rgba(255, 255, 255, 0.85); 23 | backdrop-filter: blur(30px) saturate(180%); 24 | 25 | border-radius: 8px; 26 | 27 | min-width: 180px; 28 | 29 | box-shadow: 30 | 0 12px 32px rgba(0, 0, 0, 0.08), 31 | 0 8px 16px rgba(0, 0, 0, 0.08), 32 | 0 1px 8px rgba(0, 0, 0, 0.08); 33 | 34 | padding: 10px 12px; 35 | 36 | display: flex; 37 | flex-direction: column; 38 | gap: 8px; 39 | 40 | pointer-events: none; 41 | 42 | opacity: 0; 43 | 44 | transform-origin: top left; 45 | transform: scale(0.98); 46 | 47 | &.open { 48 | pointer-events: auto; 49 | 50 | transition: 51 | opacity 80ms ease, 52 | transform 120ms ease; 53 | 54 | opacity: 1; 55 | transform: scale(1); 56 | } 57 | 58 | // No actions available 59 | &:empty { 60 | &::before { 61 | content: 'No actions available'; 62 | font-size: 14px; 63 | font-weight: 500; 64 | height: 32px; 65 | padding: 0 12px; 66 | display: flex; 67 | align-items: center; 68 | justify-content: center; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/context-menu/context-menu.tsx: -------------------------------------------------------------------------------- 1 | // import { $isContextMenuOpen, $selectedNodes } from '@/atoms' 2 | // import { 3 | // commandFocusPage, 4 | // commandRemoveNodes, 5 | // commandUnwrapNode, 6 | // commandWrapNodes, 7 | // } from '@/command' 8 | // import { keepNodeSelectionAttribute } from '@/data-attributes' 9 | // import { PageNode } from '@/node-class/page' 10 | // import { getChildNodeIndex, isUnwrappableNode } from '@/node-lib' 11 | // import { useStore } from '@nanostores/react' 12 | // import { ChevronDownIcon } from '@radix-ui/react-icons' 13 | // import clsx from 'clsx' 14 | // import { atom } from 'nanostores' 15 | // import { ContextMenuButton } from './context-menu-button' 16 | // import styles from './context-menu.module.scss' 17 | 18 | // export type ContextMenuPosition = { 19 | // x: number 20 | // y: number 21 | // } 22 | 23 | // export const $contextMenuPosition = atom({ x: 0, y: 0 }) 24 | 25 | // export const contextMenuClassName = 'studio-context-menu' 26 | 27 | // export function ContextMenu() { 28 | // const isContextMenuOpen = useStore($isContextMenuOpen) 29 | // const { x, y } = useStore($contextMenuPosition) 30 | // // const $selectedNodes = useStore($selectedNodeAtoms) 31 | // const selectedNodes = useStore($selectedNodes) 32 | 33 | // const canWrap = (() => { 34 | // if (selectedNodes.length === 0) return false 35 | 36 | // return selectedNodes.every( 37 | // (node) => 38 | // node.nodeName !== 'Page' && 39 | // node.parent?.id === selectedNodes[0].parent?.id, 40 | // ) 41 | // })() 42 | 43 | // const canUnwrap = (() => { 44 | // if (selectedNodes.length !== 1) return false 45 | 46 | // if (!selectedNodes[0]) return false 47 | 48 | // return isUnwrappableNode(selectedNodes[0]) 49 | // })() 50 | 51 | // const canMoveForward = (() => { 52 | // if (selectedNodes.length !== 1) return false 53 | 54 | // if (!selectedNodes[0]) return false 55 | 56 | // return (getChildNodeIndex(selectedNodes[0]) ?? -Infinity) > 0 57 | // })() 58 | 59 | // const canMoveBackward = (() => { 60 | // if (selectedNodes.length !== 1) return false 61 | 62 | // if (!selectedNodes[0]) return false 63 | 64 | // return ( 65 | // (getChildNodeIndex(selectedNodes[0]) ?? Infinity) < 66 | // (selectedNodes[0]?.parent?.children.length ?? -Infinity) - 1 67 | // ) 68 | // })() 69 | 70 | // const canSelectChildren = 71 | // selectedNodes.length === 1 && 72 | // selectedNodes[0].children.length > 0 && 73 | // selectedNodes[0].children.every((child) => !child.isUnselectable) 74 | 75 | // const canSelectParent = selectedNodes.every((node) => { 76 | // const parent = node.parent 77 | // return ( 78 | // parent && 79 | // parent.id === selectedNodes[0]?.parent?.id && 80 | // !parent.isUnselectable 81 | // ) 82 | // }) 83 | 84 | // const canSelectPreviousSibling = 85 | // selectedNodes.length === 1 && selectedNodes[0].previousSibling !== null 86 | 87 | // const canSelectNextSibling = 88 | // selectedNodes.length === 1 && selectedNodes[0].nextSibling !== null 89 | 90 | // const canRemove = selectedNodes.length > 0 91 | 92 | // return ( 93 | // <> 94 | //
{ 100 | // $isContextMenuOpen.set(false) 101 | // }} 102 | // /> 103 | //
113 | // {canSelectChildren && ( 114 | // $selectedNodes.set(selectedNodes[0].children)} 118 | // /> 119 | // )} 120 | 121 | // {canSelectParent && ( 122 | // { 126 | // if (selectedNodes[0]?.parent) { 127 | // $selectedNodes.set([selectedNodes[0].parent]) 128 | // } 129 | // }} 130 | // /> 131 | // )} 132 | 133 | // {canWrap && ( 134 | // <> 135 | // { 139 | // const flexNode = commandWrapNodes( 140 | // selectedNodes, 141 | // 'RadixContainer', 142 | // ) 143 | 144 | // if (flexNode) { 145 | // $selectedNodes.set([flexNode]) 146 | // } 147 | // }} 148 | // /> 149 | // { 153 | // const flexNode = commandWrapNodes(selectedNodes, 'RadixFlex') 154 | 155 | // if (flexNode) { 156 | // $selectedNodes.set([flexNode]) 157 | // } 158 | // }} 159 | // /> 160 | // 161 | // )} 162 | 163 | // {canUnwrap && ( 164 | // { 168 | // if (selectedNodes[0] && isUnwrappableNode(selectedNodes[0])) { 169 | // const children = selectedNodes[0].children 170 | // commandUnwrapNode(selectedNodes[0]) 171 | // $selectedNodes.set(children) 172 | // } 173 | // }} 174 | // /> 175 | // )} 176 | 177 | // {canSelectPreviousSibling && ( 178 | // { 182 | // if (selectedNodes[0]?.previousSibling) { 183 | // $selectedNodes.set([selectedNodes[0].previousSibling]) 184 | // } 185 | // }} 186 | // /> 187 | // )} 188 | 189 | // {canSelectNextSibling && ( 190 | // { 194 | // if (selectedNodes[0]?.nextSibling) { 195 | // $selectedNodes.set([selectedNodes[0].nextSibling]) 196 | // } 197 | // }} 198 | // /> 199 | // )} 200 | 201 | // {canMoveForward && ( 202 | // { 206 | // const parent = selectedNodes[0]?.parent 207 | // const previousSibling = selectedNodes[0]?.previousSibling 208 | 209 | // if (previousSibling && parent) { 210 | // parent.insertBefore(selectedNodes[0], previousSibling) 211 | // } 212 | // }} 213 | // /> 214 | // )} 215 | 216 | // {canMoveBackward && ( 217 | // { 221 | // const parent = selectedNodes[0]?.parent 222 | // const nextSibling = selectedNodes[0]?.nextSibling 223 | 224 | // if (nextSibling && parent) { 225 | // parent.insertBefore(selectedNodes[0], nextSibling.nextSibling) 226 | // } 227 | // }} 228 | // /> 229 | // )} 230 | 231 | // {selectedNodes.length === 1 && selectedNodes[0] instanceof PageNode && ( 232 | // commandFocusPage(selectedNodes[0] as PageNode, true)} 236 | // /> 237 | // )} 238 | 239 | // {canSelectChildren && ( 240 | // commandRemoveNodes(selectedNodes[0].children)} 245 | // /> 246 | // )} 247 | 248 | // {canRemove && ( 249 | // commandRemoveNodes(selectedNodes)} 254 | // /> 255 | // )} 256 | //
257 | // 258 | // ) 259 | 260 | // // return createPortal( 261 | // // <> 262 | // //
{ 270 | // // $isContextMenuOpen.set(false) 271 | // // }} 272 | // // /> 273 | // //
285 | // // {canSelectChildren && ( 286 | // // $selectedNodes.set(selectedNodes[0].children)} 290 | // // /> 291 | // // )} 292 | 293 | // // {canSelectParent && ( 294 | // // { 298 | // // if (selectedNodes[0]?.parent) { 299 | // // $selectedNodes.set([selectedNodes[0].parent]) 300 | // // } 301 | // // }} 302 | // // /> 303 | // // )} 304 | 305 | // // {canWrap && ( 306 | // // <> 307 | // // { 311 | // // const flexNode = commandWrapNodes(selectedNodes, 'Container') 312 | 313 | // // if (flexNode) { 314 | // // $selectedNodes.set([flexNode]) 315 | // // } 316 | // // }} 317 | // // /> 318 | // // { 322 | // // const flexNode = commandWrapNodes(selectedNodes, 'Flex') 323 | 324 | // // if (flexNode) { 325 | // // $selectedNodes.set([flexNode]) 326 | // // } 327 | // // }} 328 | // // /> 329 | // // 330 | // // )} 331 | 332 | // // {canUnwrap && ( 333 | // // { 337 | // // if (selectedNodes[0] && isUnwrappableNode(selectedNodes[0])) { 338 | // // const children = selectedNodes[0].children 339 | // // commandUnwrapNode(selectedNodes[0]) 340 | // // $selectedNodes.set(children) 341 | // // } 342 | // // }} 343 | // // /> 344 | // // )} 345 | 346 | // // {canSelectPreviousSibling && ( 347 | // // { 351 | // // if (selectedNodes[0]?.previousSibling) { 352 | // // $selectedNodes.set([selectedNodes[0].previousSibling]) 353 | // // } 354 | // // }} 355 | // // /> 356 | // // )} 357 | 358 | // // {canSelectNextSibling && ( 359 | // // { 363 | // // if (selectedNodes[0]?.nextSibling) { 364 | // // $selectedNodes.set([selectedNodes[0].nextSibling]) 365 | // // } 366 | // // }} 367 | // // /> 368 | // // )} 369 | 370 | // // {canMoveForward && ( 371 | // // { 375 | // // const parent = selectedNodes[0]?.parent 376 | // // const previousSibling = selectedNodes[0]?.previousSibling 377 | 378 | // // if (previousSibling && parent) { 379 | // // parent.insertBefore([selectedNodes[0]], previousSibling) 380 | // // } 381 | // // }} 382 | // // /> 383 | // // )} 384 | 385 | // // {canMoveBackward && ( 386 | // // { 390 | // // const parent = selectedNodes[0]?.parent 391 | // // const nextSibling = selectedNodes[0]?.nextSibling 392 | 393 | // // if (nextSibling && parent) { 394 | // // parent.insertBefore([selectedNodes[0]], nextSibling.nextSibling) 395 | // // } 396 | // // }} 397 | // // /> 398 | // // )} 399 | 400 | // // {selectedNodes.length === 1 && selectedNodes[0] instanceof PageNode && ( 401 | // // commandFocusPage(selectedNodes[0] as PageNode, true)} 405 | // // /> 406 | // // )} 407 | 408 | // // {canSelectChildren && ( 409 | // // commandDeleteNodes(selectedNodes[0].children)} 413 | // // /> 414 | // // )} 415 | 416 | // // {canRemove && ( 417 | // // commandDeleteNodes(selectedNodes)} 421 | // // /> 422 | // // )} 423 | // //
424 | // // , 425 | // // document.body, 426 | // // ) 427 | // } 428 | -------------------------------------------------------------------------------- /src/control-center/control-center-node.tsx: -------------------------------------------------------------------------------- 1 | import { $selectedNodes, triggerRerenderGuides } from '@/atoms' 2 | import { commandRemoveNodes } from '@/command' 3 | import { keepNodeSelectionAttribute } from '@/data-attributes' 4 | import { studioApp } from '@/studio-app' 5 | import { useStore } from '@nanostores/react' 6 | import { DotsHorizontalIcon, TrashIcon } from '@radix-ui/react-icons' 7 | import { 8 | Box, 9 | Button, 10 | DropdownMenu, 11 | Flex, 12 | Heading, 13 | IconButton, 14 | Select, 15 | Separator, 16 | Text, 17 | } from '@radix-ui/themes' 18 | import { map } from 'nanostores' 19 | import { useEffect, useState } from 'react' 20 | import { SelectControls, SwitchControls, TextFieldControls } from './controls' 21 | import { TSX } from './tsx' 22 | 23 | export function ControlCenterNode() { 24 | const [_, update] = useState({}) 25 | 26 | const selectedNodes = useStore($selectedNodes) 27 | const areAllSelectedNodesTheSame = selectedNodes.every( 28 | (node) => node.nodeName === selectedNodes[0]?.nodeName, 29 | ) 30 | const firstSelectedNode = selectedNodes[0] 31 | useStore(firstSelectedNode?.$style ?? map({})) 32 | 33 | const nodeDefinition = firstSelectedNode?.definition 34 | 35 | useEffect(() => { 36 | const unsubscribes = selectedNodes.map((node) => { 37 | return node.$style.listen(() => { 38 | update({}) 39 | }) 40 | }) 41 | 42 | return () => { 43 | unsubscribes.forEach((unsubscribe) => unsubscribe()) 44 | } 45 | }, [selectedNodes]) 46 | 47 | return ( 48 | 49 | 50 | {!firstSelectedNode && ( 51 | 69 | )} 70 | 71 | {firstSelectedNode && areAllSelectedNodesTheSame && ( 72 | <> 73 | 74 | 75 | {firstSelectedNode.nodeName} 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | { 88 | e.preventDefault() 89 | e.stopPropagation() 90 | }} 91 | onClick={() => { 92 | commandRemoveNodes(selectedNodes) 93 | }} 94 | > 95 | Remove 96 | 97 | 98 | 99 | 100 | 101 | 102 | {nodeDefinition.props && ( 103 | 104 | {nodeDefinition.props.map((prop) => { 105 | if (prop.format.type === 'options') { 106 | return ( 107 | node.$props, 112 | )} 113 | propertyKey={prop.key} 114 | defaultValue={prop.default} 115 | options={[ 116 | ...(prop.required 117 | ? [] 118 | : [ 119 | { 120 | label: 'Unset', 121 | value: 'undefined', 122 | }, 123 | ]), 124 | ...prop.format.options.map((option) => { 125 | if (typeof option === 'string') { 126 | return { 127 | label: option, 128 | value: option, 129 | } 130 | } 131 | 132 | return { 133 | label: option.label, 134 | value: option.value, 135 | } 136 | }), 137 | ]} 138 | /> 139 | ) 140 | } else if (prop.format.type === 'boolean') { 141 | return ( 142 | node.$props, 147 | )} 148 | propertyKey={prop.key} 149 | defaultValue={prop.default} 150 | /> 151 | ) 152 | } else if (prop.format.type === 'string') { 153 | return ( 154 | node.$props, 159 | )} 160 | propertyKey={prop.key} 161 | defaultValue={prop.default} 162 | /> 163 | ) 164 | } 165 | })} 166 | 167 | 168 | 169 | Custom Styles 170 | 171 | {Array.from( 172 | new Set( 173 | selectedNodes.flatMap((node) => 174 | Object.keys(node.$style.get()), 175 | ), 176 | ), 177 | ) 178 | .sort() 179 | .map((styleKey) => ( 180 | node.$style)} 184 | propertyKey={styleKey} 185 | defaultValue={undefined} 186 | extraButton={ 187 | { 191 | selectedNodes.forEach((node) => { 192 | node.$style.setKey(styleKey, undefined) 193 | }) 194 | 195 | triggerRerenderGuides(true) 196 | }} 197 | > 198 | 199 | 200 | } 201 | /> 202 | ))} 203 | 204 | 205 | { 208 | selectedNodes.forEach((node) => { 209 | node.$style.setKey(value, '') 210 | }) 211 | }} 212 | > 213 | 217 | 222 | {['flex', 'margin', 'padding', 'maxWidth'].map( 223 | (styleKey) => ( 224 | 225 | {styleKey} 226 | 227 | ), 228 | )} 229 | 230 | 231 | 232 | 233 | )} 234 | 235 | {selectedNodes.length === 1 && ( 236 | <> 237 | 238 | 239 | 240 | )} 241 | 242 | 243 | )} 244 | 245 | 246 | ) 247 | } 248 | -------------------------------------------------------------------------------- /src/control-center/control-center.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ScrollArea, Tabs, Text } from '@radix-ui/themes' 2 | import { ControlCenterNode } from './control-center-node' 3 | 4 | export function ControlCenter() { 5 | return ( 6 | 18 | 19 | Node 20 | Store 21 | 22 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Store 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/control-center/controls.tsx: -------------------------------------------------------------------------------- 1 | import { PropChangeAction } from '@/action' 2 | import { $selectedNodes, triggerRerenderGuides } from '@/atoms' 3 | import { keepNodeSelectionAttribute } from '@/data-attributes' 4 | import { History, HistoryStackItem } from '@/history' 5 | import { ExtractMapStoreGeneric } from '@/types/extract-generic' 6 | import { StoreKeys, useStore } from '@nanostores/react' 7 | import { Flex, Select, Switch, Text, TextField } from '@radix-ui/themes' 8 | import { MapStore } from 'nanostores' 9 | import { ReactNode, useEffect, useRef, useState } from 'react' 10 | 11 | function useCommonValue>( 12 | propMapStores: M[], 13 | key: K, 14 | defaultValue: ExtractMapStoreGeneric[K], 15 | ) { 16 | const [_, update] = useState({}) 17 | const firstProps = useStore(propMapStores[0], { keys: [key] }) // Only rerender when the key changes 18 | 19 | const firstValue = firstProps[key] 20 | const allSame = propMapStores.every( 21 | (store) => (store.get()[key] ?? defaultValue) === firstValue, 22 | ) 23 | 24 | useEffect(() => { 25 | const unsubscribes = propMapStores.map((store) => { 26 | return store.listen(() => { 27 | update({}) 28 | }) 29 | }) 30 | 31 | return () => { 32 | unsubscribes.forEach((unsubscribe) => unsubscribe()) 33 | } 34 | }, [propMapStores]) 35 | 36 | return allSame ? firstValue : undefined 37 | } 38 | 39 | type ControlsCommonFormProps> = { 40 | propMapStores: M[] 41 | controlsLabel: string 42 | propertyKey: K 43 | defaultValue: ExtractMapStoreGeneric[K] 44 | extraButton?: ReactNode 45 | } 46 | 47 | export type Option = { 48 | label: string 49 | value: V 50 | } 51 | 52 | export function SelectControls>({ 53 | controlsLabel, 54 | propMapStores, 55 | propertyKey: key, 56 | options, 57 | defaultValue, 58 | }: ControlsCommonFormProps & { 59 | options: Option[K]>[] 60 | }) { 61 | const commonValue = useCommonValue(propMapStores, key, defaultValue) 62 | 63 | return ( 64 | 65 | 66 | {controlsLabel} 67 | {/* 68 | 69 | */} 70 | 71 | { 75 | const historyStackItem: HistoryStackItem = { 76 | actions: [], 77 | previousSelectedNodes: $selectedNodes.get(), 78 | nextSelectedNodes: $selectedNodes.get(), 79 | } 80 | 81 | propMapStores.forEach((store) => { 82 | historyStackItem.actions.push( 83 | new PropChangeAction({ 84 | propMapStore: store, 85 | oldProp: { key, value: store.get()[key] }, 86 | newProp: { 87 | key, 88 | value: value === 'undefined' ? undefined : value, 89 | }, 90 | }), 91 | ) 92 | 93 | store.setKey(key, value === 'undefined' ? undefined : value) 94 | }) 95 | 96 | History.push(historyStackItem) 97 | 98 | triggerRerenderGuides(true) 99 | }} 100 | > 101 | 102 | 103 | {options.map(({ value, label }) => ( 104 | 105 | {label} 106 | 107 | ))} 108 | 109 | 110 | 111 | ) 112 | } 113 | 114 | export function SwitchControls>({ 115 | controlsLabel, 116 | propMapStores, 117 | propertyKey: key, 118 | defaultValue, 119 | }: ControlsCommonFormProps) { 120 | const commonValue = useCommonValue(propMapStores, key, defaultValue) 121 | 122 | return ( 123 | 124 | {controlsLabel} 125 | { 129 | const historyStackItem: HistoryStackItem = { 130 | actions: [], 131 | previousSelectedNodes: $selectedNodes.get(), 132 | nextSelectedNodes: $selectedNodes.get(), 133 | } 134 | 135 | propMapStores.forEach((store) => { 136 | historyStackItem.actions.push( 137 | new PropChangeAction({ 138 | propMapStore: store, 139 | oldProp: { key, value: store.get()[key] }, 140 | newProp: { key, value: checked }, 141 | }), 142 | ) 143 | 144 | store.setKey(key, checked) 145 | }) 146 | 147 | History.push(historyStackItem) 148 | 149 | triggerRerenderGuides(true) 150 | }} 151 | /> 152 | 153 | ) 154 | } 155 | 156 | export function TextFieldControls>({ 157 | controlsLabel, 158 | propMapStores, 159 | propertyKey: key, 160 | defaultValue, 161 | extraButton, 162 | }: ControlsCommonFormProps) { 163 | const previousValues = useRef([]) 164 | const timeout = useRef(0) 165 | const commonValue = useCommonValue(propMapStores, key, defaultValue) 166 | 167 | useEffect(() => { 168 | previousValues.current = propMapStores.map((store) => store.get()[key]) 169 | }, [propMapStores, key]) 170 | 171 | return ( 172 | 173 | {controlsLabel} 174 | 175 | {extraButton} 176 | 1 && commonValue === undefined 180 | ? 'Multiple values' 181 | : undefined 182 | } 183 | onChange={(e) => { 184 | const value = e.target.value 185 | 186 | propMapStores.forEach((store) => { 187 | store.setKey(key, value) 188 | }) 189 | 190 | triggerRerenderGuides(true) 191 | 192 | window.clearTimeout(timeout.current) 193 | 194 | timeout.current = window.setTimeout(() => { 195 | const historyStackItem: HistoryStackItem = { 196 | actions: [], 197 | nextSelectedNodes: $selectedNodes.get(), 198 | previousSelectedNodes: $selectedNodes.get(), 199 | } 200 | 201 | propMapStores.forEach((store, i) => { 202 | historyStackItem.actions.push( 203 | new PropChangeAction({ 204 | propMapStore: store, 205 | oldProp: { key, value: previousValues.current[i] }, 206 | newProp: { key, value }, 207 | }), 208 | ) 209 | }) 210 | 211 | History.push(historyStackItem) 212 | 213 | previousValues.current = propMapStores.map( 214 | (store) => store.get()[key], 215 | ) 216 | }, 250) 217 | }} 218 | /> 219 | 220 | 221 | ) 222 | } 223 | -------------------------------------------------------------------------------- /src/control-center/tsx.tsx: -------------------------------------------------------------------------------- 1 | import { keepNodeSelectionAttribute } from '@/data-attributes' 2 | import { format } from '@/format' 3 | import { Node } from '@/node-class/node' 4 | import { PageNode } from '@/node-class/page' 5 | import { useStore } from '@nanostores/react' 6 | import { CheckIcon, ClipboardIcon, SizeIcon } from '@radix-ui/react-icons' 7 | import { 8 | Box, 9 | Button, 10 | Card, 11 | Dialog, 12 | Flex, 13 | IconButton, 14 | Inset, 15 | ScrollArea, 16 | Text, 17 | } from '@radix-ui/themes' 18 | import { pascalCase } from 'change-case' 19 | import hljs from 'highlight.js' 20 | import { useEffect, useRef, useState } from 'react' 21 | 22 | export async function generateSourceCode(node: Node) { 23 | // TODO: allow only available javascript function name 24 | const componentName = 25 | node instanceof PageNode 26 | ? pascalCase(node.$props.get().title.trim() || 'UntitledPage') 27 | : pascalCase(node.nodeName) 28 | 29 | const sourceCode = ` 30 | function ${componentName}() { 31 | return ${await node.generateCode()} 32 | } 33 | ` 34 | 35 | const formatted = await format(sourceCode) 36 | 37 | return formatted 38 | } 39 | 40 | /** 41 | * TODO: Add large view button 42 | */ 43 | export function TSX({ node }: { node: Node }) { 44 | const props = useStore(node.$props) 45 | const pageTitle = node instanceof PageNode ? props.title : undefined 46 | const copyTimeout = useRef(0) 47 | const [copied, setCopied] = useState(false) 48 | const sourceCode = useRef('') 49 | const [syntaxHighlighted, setSyntaxHighlighted] = useState('') 50 | 51 | useEffect(() => { 52 | generateSourceCode(node).then((code): void => { 53 | sourceCode.current = code 54 | const highlighted = hljs.highlight(code, { 55 | language: 'tsx', 56 | }).value 57 | setSyntaxHighlighted(highlighted) 58 | }) 59 | }, [node, props, pageTitle]) 60 | 61 | function copyToClipboard() { 62 | navigator.clipboard.writeText(sourceCode.current) 63 | setCopied(true) 64 | 65 | window.clearTimeout(copyTimeout.current) 66 | 67 | copyTimeout.current = window.setTimeout(() => { 68 | setCopied(false) 69 | }, 2000) 70 | } 71 | 72 | return ( 73 | 77 | 78 | TSX 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | TSX 90 | 91 | 92 | 93 | 94 | 95 |
103 |                         
111 |                       
112 |
113 |
114 |
115 |
116 | 117 | 118 | 123 | {copied ? : } 124 | 125 | 126 | 127 | 128 | 129 |
130 |
131 | 132 | 137 | {copied ? : } 138 | 139 |
140 |
141 | 142 | 143 | 144 | 145 | 146 | 147 |
155 |                   
163 |                 
164 |
165 |
166 |
167 |
168 |
169 |
170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /src/data-attributes.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './node-class/node' 2 | 3 | export const dataAttributes = { 4 | node: 'data-studio-node', 5 | nodeId: 'data-studio-node-id', 6 | ownerPageId: 'data-studio-owner-page-id', 7 | keepNodeSelection: 'data-studio-keep-node-selection', 8 | dropZone: 'data-studio-drop-zone', 9 | dropZoneId: 'data-studio-drop-zone-id', 10 | dropZoneOwnerPageId: 'data-studio-drop-zone-owner-page-id', 11 | dropZoneTargetNodeId: 'data-studio-drop-zone-target-node-id', 12 | dropZoneBefore: 'data-studio-drop-zone-before', 13 | } 14 | 15 | export const keepNodeSelectionAttribute = { 16 | [dataAttributes.keepNodeSelection]: 'true', 17 | } 18 | 19 | export function makeNodeBaseAttrs(node: Node) { 20 | return { 21 | [dataAttributes.node]: 'true', 22 | [dataAttributes.nodeId]: node.id, 23 | [dataAttributes.ownerPageId]: node.ownerPage?.id, 24 | } 25 | } 26 | 27 | export function makeNodeDropZoneAttrs(node: Node) { 28 | return { 29 | [dataAttributes.dropZone]: 'true', 30 | [dataAttributes.dropZoneId]: node.id, 31 | [dataAttributes.dropZoneTargetNodeId]: node.id, 32 | [dataAttributes.dropZoneBefore]: '', 33 | [dataAttributes.dropZoneOwnerPageId]: node.ownerPage?.id, 34 | } 35 | } 36 | 37 | export function makeDropZoneAttributes(dropZoneData: { 38 | dropZoneId: string 39 | dropZoneTargetNodeId: string 40 | dropZoneBefore: string | undefined 41 | }) { 42 | const { dropZoneId, dropZoneTargetNodeId, dropZoneBefore } = dropZoneData 43 | 44 | return { 45 | [dataAttributes.dropZone]: 'true', 46 | [dataAttributes.dropZoneId]: dropZoneId, 47 | [dataAttributes.dropZoneTargetNodeId]: dropZoneTargetNodeId, 48 | [dataAttributes.dropZoneBefore]: dropZoneBefore, 49 | } 50 | } 51 | 52 | /** 53 | * TODO: instead of checking closest data attribute, stop propagation of the event 54 | */ 55 | export function shouldKeepNodeSelection(target: Element) { 56 | return target.closest(`[${dataAttributes.keepNodeSelection}]`) 57 | } 58 | 59 | export function makeNodeAttrs(node: Node) { 60 | return { 61 | id: `node-${node.id}`, 62 | ...makeNodeBaseAttrs(node), 63 | ...(node.isDroppable ? makeNodeDropZoneAttrs(node) : undefined), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/drawer/drawer-item-wrapper.module.scss: -------------------------------------------------------------------------------- 1 | .drawerItemWrapper { 2 | display: flex; 3 | } 4 | 5 | .drawerItemComponentWrapper { 6 | // pointer-events: none; 7 | display: flex; 8 | flex-direction: column; 9 | 10 | user-select: none; 11 | 12 | > * { 13 | pointer-events: none; 14 | } 15 | } 16 | 17 | .drawerItemGhost { 18 | pointer-events: none; 19 | opacity: 0.8; 20 | 21 | user-select: none; 22 | 23 | position: fixed; 24 | } 25 | -------------------------------------------------------------------------------- /src/drawer/drawer-item-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { $selectedNodes } from '@/atoms' 2 | import { commandInsertNodes } from '@/command' 3 | import { keepNodeSelectionAttribute } from '@/data-attributes' 4 | import { onMouseDownForDragAndDropNode } from '@/events' 5 | import { Node } from '@/node-class/node' 6 | import { ReactNode, useRef } from 'react' 7 | import styles from './drawer-item-wrapper.module.scss' 8 | 9 | export function DrawerItemWrapper({ 10 | children, 11 | createNode, 12 | }: { 13 | children: ReactNode 14 | createNode: () => Node 15 | }) { 16 | const ref = useRef(null!) 17 | 18 | return ( 19 |
20 |
{ 24 | const cloneTargetElm = e.currentTarget 25 | const rect = cloneTargetElm.getBoundingClientRect() 26 | 27 | onMouseDownForDragAndDropNode(e, { 28 | draggingNodes: [createNode()], 29 | cloneTargetElm: ref.current.firstElementChild!, 30 | elmX: e.clientX - rect.left, 31 | elmY: e.clientY - rect.top, 32 | elementScale: 1, 33 | draggingElm: ref.current.firstElementChild!, 34 | }) 35 | }} 36 | onClick={() => { 37 | const selectedNodes = $selectedNodes.get() 38 | if (selectedNodes.length !== 1) return 39 | if ( 40 | !selectedNodes[0].parent || 41 | (selectedNodes[0].isDroppable && 42 | selectedNodes[0].children.length === 0) 43 | ) { 44 | commandInsertNodes(selectedNodes[0], [createNode()], null) 45 | } else if (selectedNodes[0].parent) { 46 | commandInsertNodes( 47 | selectedNodes[0].parent, 48 | [createNode()], 49 | selectedNodes[0].nextSibling, 50 | ) 51 | } 52 | }} 53 | > 54 | {children} 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/drawer/drawer.module.scss: -------------------------------------------------------------------------------- 1 | .drawer { 2 | position: absolute; 3 | top: 0; 4 | right: 280px; 5 | bottom: 0; 6 | background-color: #fff; 7 | z-index: 100; 8 | border-left: 1px solid var(--accent-a6); 9 | 10 | background-color: var(--color-background); 11 | 12 | transform: translateX(100%); 13 | transition: transform 320ms cubic-bezier(0.37, 0.24, 0, 1); 14 | will-change: transform; 15 | 16 | &.open { 17 | transform: translateX(0); 18 | } 19 | } 20 | 21 | .resizer { 22 | z-index: 1; 23 | 24 | &:hover { 25 | cursor: ns-resize; 26 | 27 | .separator { 28 | background-color: var(--indigo-a9); 29 | height: 4px; 30 | 31 | transition: 32 | background-color 200ms ease, 33 | height 200ms ease; 34 | transition-delay: 200ms; 35 | } 36 | } 37 | 38 | @at-root :global(.resizing-drawer) & { 39 | .separator { 40 | background-color: var(--indigo-a9); 41 | height: 4px; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/drawer/drawer.tsx: -------------------------------------------------------------------------------- 1 | import { keepNodeSelectionAttribute } from '@/data-attributes' 2 | import { EditorState } from '@/editor-state' 3 | import { Library, stringifyLibraryKey } from '@/library' 4 | import { Node } from '@/node-class/node' 5 | import { CubeIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons' 6 | import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes' 7 | import { useEffect, useRef, useState } from 'react' 8 | import { DrawerItemWrapper } from './drawer-item-wrapper' 9 | import styles from './drawer.module.scss' 10 | 11 | const studioLibrary: Library = { 12 | name: 'studio', 13 | version: '1.0.0', 14 | } 15 | 16 | const radixThemesLibrary: Library = { 17 | name: 'radix-themes', 18 | version: '3.0.1', 19 | } 20 | 21 | function createTextNode(value: string) { 22 | return new Node({ 23 | library: studioLibrary, 24 | nodeName: 'Text', 25 | props: { value }, 26 | }) 27 | } 28 | 29 | export function Drawer({ library }: { library: Library }) { 30 | const ref = useRef(null) 31 | const [drawerItems, setDrawerItems] = useState< 32 | { 33 | createNode: () => Node 34 | render: () => JSX.Element 35 | }[] 36 | >([]) 37 | 38 | useEffect(() => { 39 | const unsubscribe = EditorState.$drawerOpen.listen((drawerOpen) => { 40 | if (drawerOpen) { 41 | ref.current?.classList.add(styles.open) 42 | } else { 43 | ref.current?.classList.remove(styles.open) 44 | } 45 | }) 46 | 47 | return () => { 48 | unsubscribe() 49 | } 50 | }, []) 51 | 52 | useEffect(() => { 53 | import(`@/libraries/${stringifyLibraryKey(library)}/drawer-items`).then( 54 | (mod) => { 55 | setDrawerItems(mod.drawerItems) 56 | return 57 | }, 58 | ) 59 | }, [library]) 60 | 61 | return ( 62 | 68 | 80 | 81 | 82 | 83 | Drawer 84 | 85 | 86 | { 89 | EditorState.$drawerOpen.set(false) 90 | }} 91 | > 92 | 93 | 94 | 95 | 96 | 97 | 98 | {drawerItems.map(({ createNode, render }, i) => ( 99 | 100 | {render()} 101 | 102 | ))} 103 | 104 | 105 | 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /src/easel/easel-container.module.scss: -------------------------------------------------------------------------------- 1 | .easelContainer { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | 6 | transform-origin: top left; // Important for ground zoom in/out 7 | } 8 | -------------------------------------------------------------------------------- /src/easel/easel-container.tsx: -------------------------------------------------------------------------------- 1 | import { EaselWrapper } from '@/easel/easel-wrapper' 2 | import { Ground } from '@/ground' 3 | import { studioApp } from '@/studio-app' 4 | import { useStore } from '@nanostores/react' 5 | import { useEffect, useRef } from 'react' 6 | import styles from './easel-container.module.scss' 7 | 8 | export const EASEL_CONTAINER_ID = 'studio-easel-container' 9 | 10 | function scaleStyle(scale: number) { 11 | return scale.toString() 12 | } 13 | 14 | function translateStyle(translate: { x: number; y: number }) { 15 | return `${translate.x}px ${translate.y}px` 16 | } 17 | 18 | /** 19 | * A container of easels (pages) 20 | */ 21 | export function EaselContainer() { 22 | const ref = useRef(null!) 23 | const pages = useStore(studioApp.$pages) 24 | 25 | useEffect(() => { 26 | const unsubscribeScale = Ground.$scale.subscribe((scale) => { 27 | ref.current.style.scale = scaleStyle(scale) 28 | }) 29 | 30 | const unsubscribeTranslate = Ground.$translate.subscribe((translate) => { 31 | ref.current.style.translate = translateStyle(translate) 32 | }) 33 | 34 | return () => { 35 | unsubscribeScale() 36 | unsubscribeTranslate() 37 | } 38 | }, []) 39 | 40 | return ( 41 |
50 | {pages.map((page) => ( 51 | 52 | ))} 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/easel/easel-wrapper.module.scss: -------------------------------------------------------------------------------- 1 | .easelWrapper { 2 | position: absolute; 3 | display: inline-flex; 4 | 5 | &:hover { 6 | .resizer { 7 | opacity: 1; 8 | } 9 | } 10 | } 11 | 12 | .resizer { 13 | position: absolute; 14 | z-index: 10; 15 | 16 | transform-origin: top left; 17 | 18 | top: 100%; 19 | left: 100%; 20 | 21 | display: flex; 22 | align-items: flex-start; 23 | justify-content: flex-start; 24 | 25 | opacity: 0; 26 | 27 | transition: opacity 200ms ease; 28 | 29 | will-change: opacity transform; 30 | 31 | &:hover { 32 | opacity: 1; 33 | } 34 | 35 | svg { 36 | transform: scale(0.5); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/easel/easel-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | $designMode, 3 | $hoveredNode, 4 | $interactiveMode, 5 | $isDraggingNode, 6 | $isResizingIframe, 7 | $massMode, 8 | } from '@/atoms' 9 | import { keepNodeSelectionAttribute, makeNodeAttrs } from '@/data-attributes' 10 | import { onMouseDownIframe } from '@/events' 11 | import { Ground } from '@/ground' 12 | import { PageNode } from '@/node-class/page' 13 | import { getClosestSelectableNodeFromElm } from '@/node-lib' 14 | import { useStore } from '@nanostores/react' 15 | import clsx from 'clsx' 16 | import { useEffect, useRef } from 'react' 17 | import styles from './easel-wrapper.module.scss' 18 | import { PageTitle } from './page-title' 19 | import { Resizer } from './resizer' 20 | 21 | export const EASEL_WRAPPER_CLASS_NAME = 'studio-easel-wrapper' 22 | 23 | export function getEaselIframeId(pageId: string) { 24 | return `easel-iframe-${pageId}` 25 | } 26 | 27 | /** 28 | * Previously hovered element used for simulating mouseover with mousemove. 29 | */ 30 | let previousMouseOverElement: Element | null = null 31 | 32 | // Clear previously remembered hovered node. 33 | // When node reverts back by history undo at where the cursor is by, it doesn't trigger simulated mouseover event. 34 | // Because previousMouseOverElement is not cleared. 35 | $hoveredNode.listen(() => { 36 | previousMouseOverElement = null 37 | }) 38 | 39 | export function EaselWrapper({ page }: { page: PageNode }) { 40 | const interactiveMode = useStore($interactiveMode) 41 | const iframeRef = useRef(null!) 42 | const coordinates = useStore(page.$coordinates) 43 | 44 | useEffect(() => { 45 | if (!iframeRef.current) return 46 | 47 | PageNode.attachIframeElement(page, iframeRef.current) 48 | 49 | const iframeWindow = iframeRef.current?.contentWindow! 50 | 51 | // Inject global references to iframe's window object. 52 | iframeWindow.parentFrame = iframeRef.current 53 | iframeWindow.ownerApp = page.ownerApp 54 | iframeWindow.pageNode = page 55 | 56 | // Inject shared data 57 | iframeWindow.shared = { $designMode, $massMode, $scale: Ground.$scale } 58 | 59 | iframeWindow.addEventListener('DOMContentLoaded', () => { 60 | const attributes = makeNodeAttrs(page) 61 | 62 | Object.entries(attributes).forEach(([key, value]) => { 63 | if (value) { 64 | iframeWindow.document.body.setAttribute(key, value) 65 | } 66 | }) 67 | }) 68 | 69 | return () => { 70 | PageNode.detachIframeElement(page) 71 | } 72 | }, [page]) 73 | 74 | useEffect(() => { 75 | const unsubscribe = page.$dimensions.subscribe((dimensions) => { 76 | if (iframeRef.current) { 77 | iframeRef.current.style.width = `${dimensions.width}px` 78 | iframeRef.current.style.height = `${dimensions.height}px` 79 | } 80 | }) 81 | 82 | return () => { 83 | unsubscribe() 84 | } 85 | }, [page.$dimensions]) 86 | 87 | if (!page) { 88 | return null 89 | } 90 | 91 | return ( 92 |
{ 99 | previousMouseOverElement = null 100 | $hoveredNode.set(null) 101 | }} 102 | onMouseMove={(e) => { 103 | // Hover node while moving mouse on the iframe 104 | 105 | if ($isDraggingNode.get() || $isResizingIframe.get()) { 106 | return 107 | } 108 | 109 | const rect = iframeRef.current.getBoundingClientRect() 110 | const pointScale = 1 / Ground.scale 111 | const elementAtCursor = 112 | iframeRef.current?.contentDocument?.elementFromPoint( 113 | (e.clientX - rect.left) * pointScale, 114 | (e.clientY - rect.top) * pointScale, 115 | ) 116 | 117 | // Simulate mouseover with mousemove 118 | if ( 119 | elementAtCursor && 120 | !elementAtCursor.isSameNode(previousMouseOverElement) 121 | ) { 122 | previousMouseOverElement = elementAtCursor 123 | 124 | const hoveredNode = getClosestSelectableNodeFromElm(elementAtCursor) 125 | 126 | if (hoveredNode) { 127 | $hoveredNode.set(hoveredNode) 128 | } 129 | } 130 | }} 131 | onMouseDown={(e) => onMouseDownIframe(e, page, false)} 132 | > 133 |