diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index 574144b8..999b30f1 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -3,10 +3,6 @@ set -e echo "Setting Up System Dependencies..." -sudo apt update -sudo apt remove mysql-server mysql-client -sudo apt install libcups2-dev redis-server mariadb-client-10.6 - install_wkhtmltopdf() { wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb sudo apt install ./wkhtmltox_0.12.6-1.focal_amd64.deb diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..cfc6595f --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,60 @@ +name: Build Container Image +on: + workflow_dispatch: + push: + branches: + - master + - develop + tags: + - "*" + +jobs: + build: + name: Build + + runs-on: ubuntu-latest + + steps: + - name: Checkout Entire Repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set Branch + run: | + export APPS_JSON='[{"url": "https://github.com/frappe/builder","branch": "${{ github.ref_name }}"}]' + echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV + echo "FRAPPE_BRANCH=${{ github.ref_type == 'tag' || github.ref_name == 'master' && 'version-15' || 'develop' }}" >> $GITHUB_ENV + - name: Set Image Tag + run: | + echo "IMAGE_TAG=${{ github.ref_name == 'develop' && 'develop' || 'stable' }}" >> $GITHUB_ENV + - uses: actions/checkout@v4 + with: + repository: frappe/frappe_docker + path: builds + + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + context: builds + file: builds/images/layered/Containerfile + tags: > + ghcr.io/${{ github.repository }}:${{ github.ref_name }}, + ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} + build-args: | + "FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}" + "APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}" \ No newline at end of file diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index a21499e4..5210ac26 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -29,7 +29,7 @@ jobs: ports: - 12000:6379 mariadb: - image: mariadb:10.6 + image: mariadb:10.8 env: MYSQL_ROOT_PASSWORD: root ports: diff --git a/README.md b/README.md index 706a0e42..7d0d0276 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,161 @@
- - Frappe Builder Logo - -

Frappe Builder

-

Crafting Web Pages Made Effortless!

- -![Frappe Builder](https://github.com/user-attachments/assets/e906545e-101e-4d55-8a25-2c4f6380ea5e) -[Web page design credit](https://www.figma.com/community/file/949266436474872912) + + + Frappe Builder Logo + + + +

Frappe Builder

+ +**Crafting Web Pages Made Effortless** + + +![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/builder) +[![codecov](https://codecov.io/github/frappe/builder/branch/develop/graph/badge.svg)](https://codecov.io/github/frappe/builder) +[![unittests](https://github.com/frappe/builder/actions/workflows/server-tests.yml/badge.svg)](https://github.com/frappe/builder/actions/workflows/server-tests.yml) + +
+ + + Frappe Builder Screenshot + +
+ +[Website](https://frappe.io/builder) - [Documentation](https://docs.frappe.io/builder)
-# Frappe Builder +## Frappe Builder Frappe Builder is a low-code website builder designed for simplicity, speed, and flexibility. Craft beautiful websites effortlessly with an intuitive visual builder. Whether you're a designer looking for ease or a developer seeking customization, Frappe Builder empowers you. It also features a click-to-publish option that gives you the complete end-to-end website creation experience. -## Key Features +### Motivation -- **Intuitive Visual Builder:** Simplify your workflow with a Figma-like editor. -- **Responsive Views:** Ensure your sites look great on any device without the fuss. -- **Frappe CMS Integration:** Easily fetch data from your database and create dynamic pages. -- **Scripting Capabilities:** Customize with client scripts, global scripts, and styles. -- **Efficient Workflow:** Use subtle shortcuts like image dropping and streamlined page copying and more to efficiently develop pages. -- **One-Click Publishing:** Instantly share your creations with the world in a single click. -- **Performance Excellence:** Frappe Builder does not bloat web pages with unnecessary scripts hence pages built with Frappe Builder are highly performant, consistently scoring high on Google Lighthouse tests. +Most existing solutions were either too complex, too restrictive, or difficult to integrate with the Frappe ecosystem. Additionally, pages built with these tools were often bloated with unnecessary scripts and styles. I wanted to take a stab at solving this problem while prioritising performance from day one. I aimed to address two major issues with this project: providing an intuitive way to design a web page and enabling one-click publishing. As a web developer, it helps me scratch my own itch, and I hope it helps others too. -## Getting Started +### Key Features -### Managed Hosting +- ✨ **Intuitive Visual Builder:** Simplify your workflow with a Figma-like editor. +- 📱 **Responsive Views:** Ensure your sites look great on any device without the fuss. +- 🛠️ **Frappe CMS Integration:** Easily fetch data from your database and create dynamic pages. +- 🧑‍💻 **Scripting Capabilities:** Customize with client scripts, global scripts, and styles. +- 🚀 **One-Click Publishing:** Instantly share your creation with the world in a single click. +- ⚡ **Performance Excellence:** Frappe Builder does not bloat web pages with unnecessary scripts hence pages built with Frappe Builder are highly performant, consistently scoring high on Google Lighthouse tests. -Get started with your personal or business site with a few clicks on [Frappe Cloud](https://frappecloud.com/builder/signup). +### Under the Hood -### Docker (Recommended) +- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework. +- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. -The quickest way to set up Frappe Builder and take it for a test ride. -Frappe framework is multi-tenant and supports multiple apps by default. This docker compose is just a standalone version with Frappe Builder pre-installed. Just put it behind your desired reverse-proxy if needed, and you're good to go. - -If you wish to use multiple Frappe apps or need multi-tenancy. Take a look at our production ready self-hosted workflow, or join us on Frappe Cloud to get first party support and hassle-free hosting. -**Step 1**: Setup folder and download the required files +## Getting Started (Production) - mkdir frappe-builder - cd frappe-builder +### Managed Hosting -**Step 2**: Download the required files +Get started with your personal or business site with a few clicks on Frappe Cloud - our official hosting service. +
+ + + + Try on Frappe Cloud + + +
-Docker Compose File: - wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/builder/develop/docker/docker-compose.yml +### Self Hosting -Frappe Builder bench setup script +Follow these steps to set up Frappe Builder in production: - wget -O init.sh https://raw.githubusercontent.com/frappe/builder/develop/docker/init.sh +**Step 1**: Download the easy install script -**Step 3**: Run the container and daemonize it +```bash +wget https://frappe.io/easy-install.py +``` - docker compose up -d +**Step 2**: Run the deployment command -**Step 4**: The site [http://builder.localhost](http://builder.localhost) should now be available. The default credentials are: +```bash +python3 ./easy-install.py deploy \ + --project=builder_prod_setup \ + --email=email@example.com \ + --image=ghcr.io/frappe/builder \ + --version=stable \ + --app=builder \ + --sitename subdomain.domain.tld +``` -> username: administrator -> password: admin +Replace the following parameters with your values: +- `email@example.com`: Your email address +- `subdomain.domain.tld`: Your domain name where Builder will be hosted -### Self-hosting +The script will set up a production-ready instance of Frappe Builder with all the necessary configurations in about 5 minutes. -If you prefer self-hosting, follow the official [Frappe Bench Installation](https://github.com/frappe/bench#installation) instructions. +## Getting Started (Development) -## Want to just try out or contribute? +### Docker -### Codespaces +You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, run following command: -https://github.com/frappe/builder/assets/13928957/c96ce2ce-9eb3-4bd5-8e92-0b39d971cb00 +**Step 1**: Setup folder and download the required files -1. Open [this link](https://github.com/codespaces/new?hide_repo_select=true&ref=master&repo=587413812&skip_quickstart=true&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=SoutheastAsia) and click on "Create Codespace". -2. Wait for initialization (~15 mins). -3. Run `bench start` from the terminal tab. -4. Click on the link beside "8000" port under "Ports" tab. -5. Log in with "Administrator" as the username and "admin" as the password. -6. Go to `.github.dev/builder` to access the builder interface. +```bash +mkdir frappe-builder && cd frappe-builder +wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/builder/develop/docker/docker-compose.yml +wget -O init.sh https://raw.githubusercontent.com/frappe/builder/develop/docker/init.sh +``` -### Local Setup +**Step 2**: Run the container -1. [Install Bench](https://github.com/frappe/bench). -2. Install Frappe Builder app: - ```sh - $ bench get-app builder - ``` -3. Create a site with the builder app: - ```sh - $ bench --site sitename.localhost install-app builder - ``` -4. Open the site in the browser: - ```sh - $ bench browse sitename.localhost --user Administrator - ``` -5. Access the builder page at `sitename.localhost:8000/builder` in your web browser. +```bash +docker compose up +``` -## Need help? +Wait until the setup script creates a site and you see `Current Site set to builder.localhost` in the terminal. Once done, the site [http://builder.localhost:8000](http://builder.localhost:8000) should now be available. -Join our [telegram group](https://t.me/frappebuilder) for instant help. +**Credentials:** +Username: `Administrator` +Password: `admin` -## License +### Local Setup -[GNU Affero General Public License v3.0](LICENSE) +1. [Setup Bench](https://docs.frappe.io/framework/user/en/installation). +1. In the frappe-bench directory, run `bench start` and keep it running. +1. Open a new terminal session and cd into `frappe-bench` directory and run following commands: +```bash +bench get-app builder +bench new-site builder.localhost --install-app builder +bench browse builder.localhost --user Administrator +``` +1. Access the builder page at `builder.localhost:8000/builder` in your web browser. + +**For Frontend Development** +1. Open a new terminal session and run the following commands: +```bash +cd frappe-bench/apps/builder +yarn install +yarn dev --host +``` +1. Now, you can access the site on vite dev server at `http://builder.localhost:8080` + +**Note:** You'll find all the code related to Builder's frontend inside `frappe-bench/apps/builder/frontend` + +

+ +### Links + +- [Telegram Public Group](https://t.me/frappebuilder) +- [Discuss Forum](https://discuss.frappe.io/c/frappe-builder/83) +- [Documentation](https://docs.frappe.io/builder) +- [Figma Plugin (Beta)](https://www.figma.com/community/plugin/1417835732014419099/figma-to-frappe-builder) + +
+
+
+ + + + Frappe Technologies + + +
diff --git a/builder/api.py b/builder/api.py index 4f9f01e8..3593dea1 100644 --- a/builder/api.py +++ b/builder/api.py @@ -215,3 +215,11 @@ def get_apps(): app_list += filter(lambda app: app.get("name") != "builder", apps) return app_list + + +@frappe.whitelist() +def update_page_folder(pages: list[str], folder_name: str) -> None: + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw("You do not have permission to update page folder.") + for page in pages: + frappe.db.set_value("Builder Page", page, "project_folder", folder_name, update_modified=False) diff --git a/builder/builder/doctype/builder_client_script/builder_client_script.json b/builder/builder/doctype/builder_client_script/builder_client_script.json index 928317f4..1f4acb31 100644 --- a/builder/builder/doctype/builder_client_script/builder_client_script.json +++ b/builder/builder/doctype/builder_client_script/builder_client_script.json @@ -39,7 +39,7 @@ "table_fieldname": "client_scripts" } ], - "modified": "2023-11-27 13:05:19.427647", + "modified": "2024-11-13 20:08:54.615438", "modified_by": "Administrator", "module": "Builder", "name": "Builder Client Script", @@ -73,5 +73,6 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/builder/builder/doctype/builder_page/builder_page.json b/builder/builder/doctype/builder_page/builder_page.json index 3c8e69a4..1e4f2766 100644 --- a/builder/builder/doctype/builder_page/builder_page.json +++ b/builder/builder/doctype/builder_page/builder_page.json @@ -35,7 +35,8 @@ "set_meta_tags", "options_tab", "authenticated_access", - "disable_indexing" + "disable_indexing", + "project_folder" ], "fields": [ { @@ -194,11 +195,17 @@ "fieldname": "disable_indexing", "fieldtype": "Check", "label": "Disable Indexing" + }, + { + "fieldname": "project_folder", + "fieldtype": "Link", + "label": "Project Folder", + "options": "Builder Project Folder" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-09-02 14:20:24.457766", + "modified": "2024-11-26 17:03:15.281018", "modified_by": "Administrator", "module": "Builder", "name": "Builder Page", diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index a0844378..5df16724 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -1,6 +1,7 @@ # Copyright (c) 2023, asdf and contributors # For license information, please see license.txt +import copy import os import shutil @@ -29,7 +30,7 @@ copy_img_to_asset_folder, escape_single_quotes, execute_script, - get_builder_page_preview_paths, + get_builder_page_preview_file_paths, get_template_assets_folder_path, is_component_used, ) @@ -260,7 +261,7 @@ def get_page_data(self, route_variables=None): return page_data def generate_page_preview_image(self, html=None): - public_path, local_path = get_builder_page_preview_paths(self) + public_path, local_path = get_builder_page_preview_file_paths(self) generate_preview( html or get_response_content(self.route), local_path, @@ -544,6 +545,7 @@ def extend_block(block, overridden_block): block["innerHTML"] = overridden_block["innerHTML"] component_children = block.get("children", []) overridden_children = overridden_block.get("children", []) + extended_children = [] for overridden_child in overridden_children: component_child = next( ( @@ -558,9 +560,11 @@ def extend_block(block, overridden_block): None, ) if component_child: - extend_block(component_child, overridden_child) + extended_children.append(extend_block(copy.deepcopy(component_child), overridden_child)) else: - component_children.insert(overridden_children.index(overridden_child), overridden_child) + extended_children.append(overridden_child) + block["children"] = extended_children + return block def set_dynamic_content_placeholder(block, data_key=False): diff --git a/frontend/src/utils/blockOperations.ts b/builder/builder/doctype/builder_project_folder/__init__.py similarity index 100% rename from frontend/src/utils/blockOperations.ts rename to builder/builder/doctype/builder_project_folder/__init__.py diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.js b/builder/builder/doctype/builder_project_folder/builder_project_folder.js new file mode 100644 index 00000000..630d2761 --- /dev/null +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt Ltd and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Builder Project Folder", { +// refresh(frm) { + +// }, +// }); diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.json b/builder/builder/doctype/builder_project_folder/builder_project_folder.json new file mode 100644 index 00000000..7507969b --- /dev/null +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:folder_name", + "creation": "2024-11-26 16:57:02.359763", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "folder_name" + ], + "fields": [ + { + "fieldname": "folder_name", + "fieldtype": "Data", + "label": "Folder Name", + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-11-26 17:04:15.337801", + "modified_by": "Administrator", + "module": "Builder", + "name": "Builder Project Folder", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/builder/builder/doctype/builder_project_folder/builder_project_folder.py b/builder/builder/doctype/builder_project_folder/builder_project_folder.py new file mode 100644 index 00000000..0cae5195 --- /dev/null +++ b/builder/builder/doctype/builder_project_folder/builder_project_folder.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BuilderProjectFolder(Document): + pass diff --git a/builder/builder/doctype/builder_settings/builder_settings.json b/builder/builder/doctype/builder_settings/builder_settings.json index e515ef35..2c8e853b 100644 --- a/builder/builder/doctype/builder_settings/builder_settings.json +++ b/builder/builder/doctype/builder_settings/builder_settings.json @@ -67,7 +67,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-05-27 16:54:08.439843", + "modified": "2024-11-13 20:08:03.270174", "modified_by": "Administrator", "module": "Builder", "name": "Builder Settings", @@ -91,5 +91,6 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/builder/builder/doctype/user_font/user_font.json b/builder/builder/doctype/user_font/user_font.json index 815c0bdf..ca7e2bcf 100644 --- a/builder/builder/doctype/user_font/user_font.json +++ b/builder/builder/doctype/user_font/user_font.json @@ -24,7 +24,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-09-26 11:24:22.897525", + "modified": "2024-11-13 20:08:24.187664", "modified_by": "Administrator", "module": "Builder", "name": "User Font", @@ -58,5 +58,6 @@ ], "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/builder/builder/test_utils.py b/builder/builder/test_utils.py index cd3822d7..aa917d71 100644 --- a/builder/builder/test_utils.py +++ b/builder/builder/test_utils.py @@ -7,7 +7,7 @@ camel_case_to_kebab_case, escape_single_quotes, execute_script, - get_builder_page_preview_paths, + get_builder_page_preview_file_paths, get_dummy_blocks, get_template_assets_folder_path, is_component_used, @@ -33,22 +33,22 @@ def test_is_component_used(self): self.assertTrue(is_component_used(dummy_blocks, "component-2")) self.assertFalse(is_component_used(dummy_blocks, "component-3")) - def test_get_builder_page_preview_paths(self): + def test_get_builder_page_preview_file_paths(self): page_doc = frappe._dict( { "name": "test-page", "is_template": False, } ) - public_path, local_path = get_builder_page_preview_paths(page_doc) - self.assertRegex(public_path, r"/files/test-page-preview.jpeg\?v=\w{5}") - self.assertEqual(local_path, f"{frappe.local.site_path}/public/files/test-page-preview.jpeg") + public_path, local_path = get_builder_page_preview_file_paths(page_doc) + self.assertRegex(public_path, r"/files/test-page-preview.webp\?v=\w{5}") + self.assertEqual(local_path, f"{frappe.local.site_path}/public/files/test-page-preview.webp") page_doc.is_template = True - public_path, local_path = get_builder_page_preview_paths(page_doc) - self.assertEqual(public_path, "/builder_assets/test-page/preview.jpeg") + public_path, local_path = get_builder_page_preview_file_paths(page_doc) + self.assertEqual(public_path, "/builder_assets/test-page/preview.webp") self.assertEqual( - local_path, f"{frappe.get_app_path('builder')}/www/builder_assets/test-page/preview.jpeg" + local_path, f"{frappe.get_app_path('builder')}/www/builder_assets/test-page/preview.webp" ) def test_get_template_assets_folder_path(self): diff --git a/builder/html_preview_image.py b/builder/html_preview_image.py index d24d3805..675a6560 100644 --- a/builder/html_preview_image.py +++ b/builder/html_preview_image.py @@ -1,6 +1,7 @@ +import html as html_parser + import frappe import requests -import html as html_parser # TODO: Find better alternative # Note: while working locally, "preview.frappe.cloud" won't be able to generate preview properly since it can't access local server for assets @@ -14,12 +15,10 @@ def generate_preview(html, output_path): escaped_html = html_parser.escape(html) - response = requests.post(PREVIEW_GENERATOR_URL, json={ - 'html': escaped_html, - }) + response = requests.post(PREVIEW_GENERATOR_URL, json={"html": escaped_html, "format": "webp"}) if response.status_code == 200: - with open(output_path, 'wb') as f: + with open(output_path, "wb") as f: f.write(response.content) else: - exception = response.json().get('exc') + exception = response.json().get("exc") raise Exception(frappe.parse_json(exception)[0]) diff --git a/builder/templates/generators/webpage_scripts.html b/builder/templates/generators/webpage_scripts.html index 53028639..09a4e129 100644 --- a/builder/templates/generators/webpage_scripts.html +++ b/builder/templates/generators/webpage_scripts.html @@ -14,5 +14,5 @@ {% endif %} \ No newline at end of file diff --git a/builder/utils.py b/builder/utils.py index ab950ac4..3a24ed17 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -226,13 +226,13 @@ def get_template_assets_folder_path(page_doc): return path -def get_builder_page_preview_paths(page_doc): +def get_builder_page_preview_file_paths(page_doc): public_path, public_path = None, None if page_doc.is_template: - local_path = os.path.join(get_template_assets_folder_path(page_doc), "preview.jpeg") - public_path = f"/builder_assets/{page_doc.name}/preview.jpeg" + local_path = os.path.join(get_template_assets_folder_path(page_doc), "preview.webp") + public_path = f"/builder_assets/{page_doc.name}/preview.webp" else: - file_name = f"{page_doc.name}-preview.jpeg" + file_name = f"{page_doc.name}-preview.webp" local_path = os.path.join(frappe.local.site_path, "public", "files", file_name) random_hash = frappe.generate_hash(length=5) public_path = f"/files/{file_name}?v={random_hash}" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3fcb111d..a0e5e2c6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -26,6 +26,7 @@ services: ports: - 8000:8000 - 9000:9000 + - 8080:8080 volumes: mariadb-data: diff --git a/frappe-ui b/frappe-ui index c34ce581..0f32e2e9 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit c34ce5814f94ed18dacadaa5046df65a13991435 +Subproject commit 0f32e2e95677198fe84323f17954d7b306711065 diff --git a/frontend/espresso_colors.js b/frontend/espresso_colors.js deleted file mode 100644 index dbc82265..00000000 --- a/frontend/espresso_colors.js +++ /dev/null @@ -1,83 +0,0 @@ -const espressoColors = { - outline: { - "amber-1": "var(--outline-amber-1)", - "amber-2": "var(--outline-amber-2)", - "blue-1": "var(--outline-blue-1)", - "blue-2": "var(--outline-blue-2)", - "gray-1": "var(--outline-gray-1)", - "gray-2": "var(--outline-gray-2)", - "gray-3": "var(--outline-gray-3)", - "gray-4": "var(--outline-gray-4)", - "gray-5": "var(--outline-gray-5)", - "gray-modals": "var(--outline-gray-modals)", - "green-1": "var(--outline-green-1)", - "green-2": "var(--outline-green-2)", - "orange-1": "var(--outline-orange-1)", - "red-1": "var(--outline-red-1)", - "red-2": "var(--outline-red-2)", - "red-3": "var(--outline-red-3)", - "red-4": "var(--outline-red-4)", - white: "var(--outline-white)", - }, - surface: { - "amber-1": "var(--surface-amber-1)", - "amber-2": "var(--surface-amber-2)", - "blue-1": "var(--surface-blue-1)", - "blue-2": "var(--surface-blue-2)", - cards: "var(--surface-cards)", - "cyan-1": "var(--surface-cyan-1)", - "gray-1": "var(--surface-gray-1)", - "gray-2": "var(--surface-gray-2)", - "gray-3": "var(--surface-gray-3)", - "gray-4": "var(--surface-gray-4)", - "gray-5": "var(--surface-gray-5)", - "gray-6": "var(--surface-gray-6)", - "gray-7": "var(--surface-gray-7)", - "green-1": "var(--surface-green-1)", - "green-2": "var(--surface-green-2)", - "green-3": "var(--surface-green-3)", - "menu-bar": "var(--surface-menu-bar)", - modal: "var(--surface-modal)", - "orange-1": "var(--surface-orange-1)", - "pink-1": "var(--surface-pink-1)", - "red-1": "var(--surface-red-1)", - "red-2": "var(--surface-red-2)", - "red-3": "var(--surface-red-3)", - "red-4": "var(--surface-red-4)", - "red-5": "var(--surface-red-5)", - "red-6": "var(--surface-red-6)", - selected: "var(--surface-selected)", - "violet-1": "var(--surface-violet-1)", - white: "var(--surface-white)", - }, - "text-icons": { - "amber-1": "var(--text-icons-amber-1)", - "amber-2": "var(--text-icons-amber-2)", - "amber-3": "var(--text-icons-amber-3)", - "blue-1": "var(--text-icons-blue-1)", - "blue-2": "var(--text-icons-blue-2)", - "blue-3": "var(--text-icons-blue-3)", - "cyan-1": "var(--text-icons-cyan-1)", - "gray-1": "var(--text-icons-gray-1)", - "gray-2": "var(--text-icons-gray-2)", - "gray-3": "var(--text-icons-gray-3)", - "gray-4": "var(--text-icons-gray-4)", - "gray-5": "var(--text-icons-gray-5)", - "gray-6": "var(--text-icons-gray-6)", - "gray-7": "var(--text-icons-gray-7)", - "gray-8": "var(--text-icons-gray-8)", - "gray-9": "var(--text-icons-gray-9)", - "green-1": "var(--text-icons-green-1)", - "green-2": "var(--text-icons-green-2)", - "green-3": "var(--text-icons-green-3)", - "pink-1": "var(--text-icons-pink-1)", - "red-1": "var(--text-icons-red-1)", - "red-2": "var(--text-icons-red-2)", - "red-3": "var(--text-icons-red-3)", - "red-4": "var(--text-icons-red-4)", - "violet-1": "var(--text-icons-violet-1)", - white: "var(--text-icons-white)", - }, -}; - -export default espressoColors; diff --git a/frontend/package.json b/frontend/package.json index a9a9a9a4..64a569e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "ace-builds": "^1.22.0", "autoprefixer": "^10.4.2", "feather-icons": "^4.28.0", - "frappe-ui": "^0.1.43", + "frappe-ui": "0.1.93", "opentype.js": "^1.3.4", "pinia": "^2.0.28", "postcss": "^8.4.5", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b1abe423..d1c21181 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -52,8 +52,8 @@ const isDark = useDark({ [id^="headlessui-dialog-panel"] > div > div > div > div.mb-6.flex.items-center.justify-between > button { @apply bg-surface-gray-1; @apply hover:bg-surface-gray-3; - @apply stroke-text-icons-gray-8; - @apply hover:stroke-text-icons-gray-9; + @apply stroke-ink-gray-8; + @apply hover:stroke-ink-gray-9; > svg { @apply stroke-[0.2px]; @apply h-[14px]; @@ -63,7 +63,7 @@ const isDark = useDark({ [id^="headlessui-dialog-panel"] > div, [id^="headlessui-dialog-panel"] .space-y-4 > p { @apply bg-surface-white; - @apply text-text-icons-gray-8; + @apply text-ink-gray-8; } [id^="headlessui-dialog-panel"] header h3 { @@ -82,7 +82,7 @@ const isDark = useDark({ [id^="headlessui-combobox-options"] { @apply bg-surface-white; @apply dark:bg-surface-gray-1; - @apply text-text-icons-gray-7; + @apply text-ink-gray-7; @apply overflow-y-auto; -ms-overflow-style: none; /* IE and Edge */ diff --git a/frontend/src/colors.css b/frontend/src/colors.css deleted file mode 100644 index 77a64741..00000000 --- a/frontend/src/colors.css +++ /dev/null @@ -1,152 +0,0 @@ -@layer base { - :root { - --outline-amber-1: rgb(254 237 169); - --outline-amber-2: rgb(251 204 85); - --outline-blue-1: rgb(167 215 253); - --outline-blue-2: rgb(2 137 247); - --outline-gray-1: rgb(237 237 237); - --outline-gray-2: rgb(226 226 226); - --outline-gray-3: rgb(199 199 199); - --outline-gray-4: rgb(153 153 153); - --outline-gray-5: rgb(56 56 56); - --outline-gray-modals: rgb(237 237 237); - --outline-green-1: rgb(185 238 204); - --outline-green-2: rgb(155 228 182); - --outline-orange-1: rgb(244 176 127); - --outline-red-1: rgb(255 216 216); - --outline-red-2: rgb(253 194 194); - --outline-red-3: rgb(247 149 150); - --outline-red-4: rgb(224 54 54); - --outline-white: rgb(255 255 255); - --surface-amber-1: rgb(255 247 211); - --surface-amber-2: rgb(219 119 6); - --surface-blue-1: rgb(230 244 255); - --surface-blue-2: rgb(2 137 247); - --surface-cards: rgb(255 255 255); - --surface-cyan-1: rgb(221 247 255); - --surface-gray-1: rgb(248 248 248); - --surface-gray-2: rgb(243 243 243); - --surface-gray-3: rgb(237 237 237); - --surface-gray-4: rgb(226 226 226); - --surface-gray-5: rgb(82 82 82); - --surface-gray-6: rgb(56 56 56); - --surface-gray-7: rgb(23 23 23); - --surface-green-1: rgb(242 253 244); - --surface-green-2: rgb(228 250 235); - --surface-green-3: rgb(48 166 109); - --surface-menu-bar: rgb(248 248 248); - --surface-modal: rgb(255 255 255); - --surface-orange-1: rgb(255 239 228); - --surface-pink-1: rgb(253 232 245); - --surface-red-1: rgb(255 231 231); - --surface-red-2: rgb(255 216 216); - --surface-red-3: rgb(253 194 194); - --surface-red-4: rgb(204 41 41); - --surface-red-5: rgb(181 42 42); - --surface-red-6: rgb(148 31 31); - --surface-selected: rgb(255 255 255); - --surface-violet-1: rgb(240 235 255); - --surface-white: rgb(255 255 255); - --text-icons-amber-1: rgb(255 247 211); - --text-icons-amber-2: rgb(231 153 19); - --text-icons-amber-3: rgb(219 119 6); - --text-icons-blue-1: rgb(230 244 255); - --text-icons-blue-2: rgb(0 123 224); - --text-icons-blue-3: rgb(0 92 163); - --text-icons-cyan-1: rgb(59 189 229); - --text-icons-gray-1: rgb(237 237 237); - --text-icons-gray-2: rgb(226 226 226); - --text-icons-gray-3: rgb(199 199 199); - --text-icons-gray-4: rgb(153 153 153); - --text-icons-gray-5: rgb(124 124 124); - --text-icons-gray-6: rgb(82 82 82); - --text-icons-gray-7: rgb(82 82 82); - --text-icons-gray-8: rgb(56 56 56); - --text-icons-gray-9: rgb(23 23 23); - --text-icons-green-1: rgb(242 253 244); - --text-icons-green-2: rgb(48 166 109); - --text-icons-green-3: rgb(22 121 76); - --text-icons-pink-1: rgb(227 74 166); - --text-icons-red-1: rgb(255 247 247); - --text-icons-red-2: rgb(247 149 150); - --text-icons-red-3: rgb(224 54 54); - --text-icons-red-4: rgb(204 41 41); - --text-icons-violet-1: rgb(104 70 227); - --text-icons-white: rgb(255 255 255); - } - [data-theme="dark"] { - --outline-amber-1: rgb(130 65 8); - --outline-amber-2: rgb(130 65 8); - --outline-blue-1: rgb(21 89 153); - --outline-blue-2: rgb(21 89 153); - --outline-gray-1: rgb(35 35 35); - --outline-gray-2: rgb(52 52 52); - --outline-gray-3: rgb(66 66 66); - --outline-gray-4: rgb(128 128 128); - --outline-gray-5: rgb(237 237 237); - --outline-gray-modals: rgb(52 52 52); - --outline-green-1: rgb(11 97 57); - --outline-green-2: rgb(10 63 39); - --outline-orange-1: rgb(130 57 6); - --outline-red-1: rgb(98 27 24); - --outline-red-2: rgb(76 24 24); - --outline-red-3: rgb(98 27 24); - --outline-red-4: rgb(156 32 32); - --outline-white: rgb(28 28 28); - --surface-amber-1: rgb(55 30 6); - --surface-amber-2: rgb(197 116 17); - --surface-blue-1: rgb(14 32 55); - --surface-blue-2: rgb(21 128 216); - --surface-cards: rgb(28 28 28); - --surface-cyan-1: rgb(11 37 45); - --surface-gray-1: rgb(35 35 35); - --surface-gray-2: rgb(255 255 255 / 0.1); - --surface-gray-3: rgb(255 255 255 / 0.18); - --surface-gray-4: rgb(66 66 66); - --surface-gray-5: rgb(212 212 212); - --surface-gray-6: rgb(175 175 175); - --surface-gray-7: rgb(212 212 212); - --surface-green-1: rgb(11 46 28); - --surface-green-2: rgb(10 63 39); - --surface-green-3: rgb(31 157 96); - --surface-menu-bar: rgb(15 15 15); - --surface-modal: rgb(35 35 35); - --surface-orange-1: rgb(64 31 7 / 0.8); - --surface-pink-1: rgb(71 20 50 / 0.8); - --surface-red-1: rgb(54 21 21 / 0.8); - --surface-red-2: rgb(76 24 24 / 0.9); - --surface-red-3: rgb(98 27 24); - --surface-red-4: rgb(228 56 56); - --surface-red-5: rgb(156 32 32); - --surface-red-6: rgb(98 27 24); - --surface-selected: rgb(255 255 255 / 0.1); - --surface-violet-1: rgb(34 28 66); - --surface-white: rgb(15 15 15); - --text-icons-amber-1: rgb(255 255 255); - --text-icons-amber-2: rgb(231 153 19); - --text-icons-amber-3: rgb(231 153 19); - --text-icons-blue-1: rgb(255 255 255); - --text-icons-blue-2: rgb(90 174 242); - --text-icons-blue-3: rgb(140 193 236); - --text-icons-cyan-1: rgb(60 184 220); - --text-icons-gray-1: rgb(35 35 35); - --text-icons-gray-2: rgb(66 66 66); - --text-icons-gray-3: rgb(113 113 113); - --text-icons-gray-4: rgb(113 113 113); - --text-icons-gray-5: rgb(128 128 128); - --text-icons-gray-6: rgb(153 153 153); - --text-icons-gray-7: rgb(175 175 175); - --text-icons-gray-8: rgb(212 212 212); - --text-icons-gray-9: rgb(248 248 248); - --text-icons-green-1: rgb(255 255 255); - --text-icons-green-2: rgb(53 174 116); - --text-icons-green-3: rgb(120 215 169); - --text-icons-pink-1: rgb(227 89 171); - --text-icons-red-1: rgb(255 255 255); - --text-icons-red-2: rgb(98 27 24); - --text-icons-red-3: rgb(235 77 82); - --text-icons-red-4: rgb(252 116 116); - --text-icons-violet-1: rgb(157 124 234); - --text-icons-white: rgb(15 15 15); - } -} \ No newline at end of file diff --git a/frontend/src/components/AppsMenu.vue b/frontend/src/components/AppsMenu.vue index 25662747..ca75a4e8 100644 --- a/frontend/src/components/AppsMenu.vue +++ b/frontend/src/components/AppsMenu.vue @@ -7,14 +7,14 @@ @click.prevent="togglePopover()">
- Apps + Apps
diff --git a/frontend/src/components/BlockEditor.vue b/frontend/src/components/BlockEditor.vue index 5a59eacb..3ed099f4 100644 --- a/frontend/src/components/BlockEditor.vue +++ b/frontend/src/components/BlockEditor.vue @@ -1,39 +1,36 @@ diff --git a/frontend/src/components/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index 3b8ccbaf..642236eb 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -2,12 +2,8 @@ { } }; -const triggerContextMenu = (e: MouseEvent) => { - if (props.block.isRoot() || isEditable.value) return; - e.stopPropagation(); - e.preventDefault(); - selectBlock(e); - nextTick(() => { - editor.value?.element.dispatchEvent(new MouseEvent("contextmenu", e)); - }); -}; - -const handleClick = (e: MouseEvent) => { - if (isEditable.value) return; - if (store.preventClick) { - e.stopPropagation(); - e.preventDefault(); - store.preventClick = false; - return; - } - selectBlock(e); - e.stopPropagation(); - e.preventDefault(); -}; - -const handleDoubleClick = (e: MouseEvent) => { - if (isEditable.value) return; - store.editableBlock = null; - if (props.block.isText() || props.block.isLink() || props.block.isButton()) { - store.editableBlock = props.block; - e.stopPropagation(); - } - - // dblclick on container adds text block or selects text block if only one child - let children = props.block.getChildren(); - if (props.block.isHTML()) { - editor.value?.element.dispatchEvent(new MouseEvent("dblclick", e)); - e.stopPropagation(); - } else if (props.block.isContainer()) { - if (!children.length) { - const child = getBlockTemplate("text"); - props.block.setBaseStyle("alignItems", "center"); - props.block.setBaseStyle("justifyContent", "center"); - const childBlock = props.block.addChild(child); - childBlock.makeBlockEditable(); - } else if (children.length === 1 && children[0].isText()) { - const child = children[0]; - child.makeBlockEditable(); - } - e.stopPropagation(); - } -}; - -const handleMouseOver = (e: MouseEvent) => { - if (store.mode === "move") return; - store.hoveredBlock = props.block.blockId; - store.hoveredBreakpoint = props.breakpoint; - e.stopPropagation(); -}; - -const handleMouseLeave = (e: MouseEvent) => { - if (store.mode === "move") return; - if (store.hoveredBlock === props.block.blockId) { - store.hoveredBlock = null; - e.stopPropagation(); - } -}; - const showBlock = computed(() => { // const data = props.block.getVisibilityCondition() // ? getDataForKey(props.data, props.block.getVisibilityCondition() as string) @@ -319,4 +248,6 @@ if (!props.preview) { }, ); } + +// Note: All the block event listeners are delegated to parent for better scalability diff --git a/frontend/src/components/BuilderBlockTemplates.vue b/frontend/src/components/BuilderBlockTemplates.vue index 3d50b3db..c2f0681c 100644 --- a/frontend/src/components/BuilderBlockTemplates.vue +++ b/frontend/src/components/BuilderBlockTemplates.vue @@ -34,7 +34,7 @@ }"> -

+

{{ blockTemplate.template_name }}

diff --git a/frontend/src/components/BuilderCanvas.vue b/frontend/src/components/BuilderCanvas.vue index 25410bd2..7c84cd23 100644 --- a/frontend/src/components/BuilderCanvas.vue +++ b/frontend/src/components/BuilderCanvas.vue @@ -4,7 +4,7 @@
@@ -26,13 +26,13 @@ class="w-auto cursor-pointer p-2" v-for="breakpoint in canvasProps.breakpoints" :key="breakpoint.device" - @click.stop="breakpoint.visible = !breakpoint.visible"> + @click.stop="(ev) => selectBreakpoint(ev, breakpoint)">
@@ -43,10 +43,11 @@ background: canvasProps.background, width: `${breakpoint.width}px`, }" - v-for="breakpoint in visibleBreakpoints" + v-for="breakpoint in renderedBreakpoints" + v-show="breakpoint.visible" :key="breakpoint.device">
import LoadingIcon from "@/components/Icons/Loading.vue"; -import { posthog } from "@/telemetry"; +import { BreakpointConfig, CanvasHistory } from "@/types/Builder/BuilderCanvas"; import Block from "@/utils/block"; -import getBlockTemplate from "@/utils/blockTemplate"; -import { - addPxToNumber, - getBlockCopy, - getBlockInstance, - getBlockObject, - getNumberFromPx, - isTargetEditable, - uploadImage, -} from "@/utils/helpers"; -import { - UseRefHistoryReturn, - clamp, - useDebouncedRefHistory, - useDropZone, - useElementBounding, - useEventListener, -} from "@vueuse/core"; +import { getBlockCopy, isCtrlOrCmd } from "@/utils/helpers"; +import { useBlockEventHandlers } from "@/utils/useBlockEventHandlers"; +import { useBlockSelection } from "@/utils/useBlockSelection"; +import { useCanvasDropZone } from "@/utils/useCanvasDropZone"; +import { useCanvasEvents } from "@/utils/useCanvasEvents"; +import { useCanvasUtils } from "@/utils/useCanvasUtils"; import { FeatherIcon } from "frappe-ui"; -import { Ref, computed, nextTick, onMounted, provide, reactive, ref, watch } from "vue"; -import { toast } from "vue-sonner"; +import { Ref, computed, onMounted, provide, reactive, ref, watch } from "vue"; import useStore from "../store"; import setPanAndZoom from "../utils/panAndZoom"; import BlockSnapGuides from "./BlockSnapGuides.vue"; @@ -111,7 +99,6 @@ const canvas = ref(null); const showBlocks = ref(false); const overlay = ref(null); const isDirty = ref(false); -let selectionTrail = [] as string[]; const props = defineProps({ blockData: { @@ -126,6 +113,16 @@ const props = defineProps({ // clone props.block into canvas data to avoid mutating them const block = ref(getBlockCopy(props.blockData, true)) as Ref; +const history = ref(null) as Ref | CanvasHistory; + +const { + clearSelection, + selectBlockRange, + selectedBlockIds, + isSelected, + toggleBlockSelection, + selectedBlocks, +} = useBlockSelection(block); const canvasProps = reactive({ overlayElement: null, @@ -143,6 +140,7 @@ const canvasProps = reactive({ displayName: "Desktop", width: 1400, visible: true, + renderedOnce: true, }, { icon: "tablet", @@ -158,403 +156,70 @@ const canvasProps = reactive({ width: 420, visible: false, }, - ], + ] as BreakpointConfig[], }); -const canvasHistory = ref(null) as Ref> | Ref; +const { + setScaleAndTranslate, + resetZoom, + moveCanvas, + zoomIn, + zoomOut, + toggleMode, + toggleDirty, + setupHistory, + clearCanvas, + getRootBlock, + setRootBlock, + selectBlock, + scrollBlockIntoView, + removeBlock, + findBlock, +} = useCanvasUtils(canvasProps, canvasContainer, canvas, block, selectedBlockIds, history); -provide("canvasProps", canvasProps); +const { isOverDropZone } = useCanvasDropZone( + canvasContainer as unknown as Ref, + block, + findBlock, +); onMounted(() => { + const canvasContainerEl = canvasContainer.value as unknown as HTMLElement; + const canvasEl = canvas.value as unknown as HTMLElement; canvasProps.overlayElement = overlay.value; + setScaleAndTranslate(); + showBlocks.value = true; setupHistory(); - setEvents(); -}); - -function setupHistory() { - canvasHistory.value = useDebouncedRefHistory(block, { - capacity: 50, - deep: true, - debounce: 200, - dump: (obj) => { - return getBlockObject(obj); - }, - parse: (obj) => { - return getBlockInstance(obj); - }, - }); -} - -const { isOverDropZone } = useDropZone(canvasContainer, { - onDrop: async (files, ev) => { - let element = document.elementFromPoint(ev.x, ev.y) as HTMLElement; - let parentBlock = block.value as Block | null; - if (element) { - if (element.dataset.blockId) { - parentBlock = findBlock(element.dataset.blockId) || parentBlock; - } - } - const componentName = ev.dataTransfer?.getData("componentName"); - const blockTemplate = ev.dataTransfer?.getData("blockTemplate"); - if (componentName) { - await store.loadComponent(componentName); - const component = store.componentMap.get(componentName) as Block; - const newBlock = getBlockCopy(component); - newBlock.extendFromComponent(componentName); - // if shift key is pressed, replace parent block with new block - if (ev.shiftKey) { - while (parentBlock && parentBlock.isChildOfComponent) { - parentBlock = parentBlock.getParentBlock(); - } - if (!parentBlock) return; - const parentParentBlock = parentBlock.getParentBlock(); - if (!parentParentBlock) return; - const index = parentParentBlock.children.indexOf(parentBlock); - parentParentBlock.children.splice(index, 1, newBlock); - } else { - while (parentBlock && !parentBlock.canHaveChildren()) { - parentBlock = parentBlock.getParentBlock(); - } - if (!parentBlock) return; - parentBlock.addChild(newBlock); - } - ev.stopPropagation(); - posthog.capture("builder_component_used"); - } else if (blockTemplate) { - await store.fetchBlockTemplate(blockTemplate); - const newBlock = getBlockInstance(store.getBlockTemplate(blockTemplate).block, false); - // if shift key is pressed, replace parent block with new block - if (ev.shiftKey) { - while (parentBlock && parentBlock.isChildOfComponent) { - parentBlock = parentBlock.getParentBlock(); - } - if (!parentBlock) return; - const parentParentBlock = parentBlock.getParentBlock(); - if (!parentParentBlock) return; - const index = parentParentBlock.children.indexOf(parentBlock); - parentParentBlock.children.splice(index, 1, newBlock); - } else { - while (parentBlock && !parentBlock.canHaveChildren()) { - parentBlock = parentBlock.getParentBlock(); - } - if (!parentBlock) return; - parentBlock.addChild(newBlock); - } - posthog.capture("builder_block_template_used", { template: blockTemplate }); - } else if (files && files.length) { - uploadImage(files[0]).then((fileDoc: { fileURL: string; fileName: string }) => { - if (!parentBlock) return; - - if (fileDoc.fileName.match(/\.(mp4|webm|ogg|mov)$/)) { - if (parentBlock.isVideo()) { - parentBlock.setAttribute("src", fileDoc.fileURL); - } else { - while (parentBlock && !parentBlock.canHaveChildren()) { - parentBlock = parentBlock.getParentBlock() as Block; - } - parentBlock.addChild(store.getVideoBlock(fileDoc.fileURL)); - } - posthog.capture("builder_video_uploaded"); - return; - } - - if (parentBlock.isImage()) { - parentBlock.setAttribute("src", fileDoc.fileURL); - posthog.capture("builder_image_uploaded", { - type: "image-replace", - }); - } else if (parentBlock.isSVG()) { - const imageBlock = store.getImageBlock(fileDoc.fileURL, fileDoc.fileName); - const parentParentBlock = parentBlock.getParentBlock(); - parentParentBlock?.replaceChild(parentBlock, getBlockInstance(imageBlock)); - posthog.capture("builder_image_uploaded", { - type: "svg-replace", - }); - } else if (parentBlock.isContainer() && ev.shiftKey) { - parentBlock.setStyle("background", `url(${fileDoc.fileURL})`); - posthog.capture("builder_image_uploaded", { - type: "background", - }); - } else { - while (parentBlock && !parentBlock.canHaveChildren()) { - parentBlock = parentBlock.getParentBlock() as Block; - } - parentBlock.addChild(store.getImageBlock(fileDoc.fileURL, fileDoc.fileName)); - posthog.capture("builder_image_uploaded", { - type: "new-image", - }); - } - }); - } - }, -}); - -const visibleBreakpoints = computed(() => { - return canvasProps.breakpoints.filter( - (breakpoint) => breakpoint.visible || breakpoint.device === "desktop", + useCanvasEvents( + canvasContainer as unknown as Ref, + canvasProps, + history as CanvasHistory, + selectedBlocks, + getRootBlock, + findBlock, ); -}); - -function setEvents() { - const container = document.body.querySelector(".canvas-container") as HTMLElement; - let counter = 0; - useEventListener(container, "mousedown", (ev: MouseEvent) => { - if (store.mode === "move") { - return; - } - const initialX = ev.clientX; - const initialY = ev.clientY; - if (store.mode === "select") { - return; - } else { - canvasHistory.value?.pause(); - ev.stopPropagation(); - let element = document.elementFromPoint(ev.x, ev.y) as HTMLElement; - let block = getFirstBlock(); - if (element) { - if (element.dataset.blockId) { - block = findBlock(element.dataset.blockId) || block; - } - } - let parentBlock = getFirstBlock(); - if (element.dataset.blockId) { - parentBlock = findBlock(element.dataset.blockId) || parentBlock; - while (parentBlock && !parentBlock.canHaveChildren()) { - parentBlock = parentBlock.getParentBlock() || getFirstBlock(); - } - } - const child = getBlockTemplate(store.mode); - const parentElement = document.body.querySelector( - `.canvas [data-block-id="${parentBlock.blockId}"]`, - ) as HTMLElement; - const parentOldPosition = parentBlock.getStyle("position"); - if (parentOldPosition === "static" || parentOldPosition === "inherit" || !parentOldPosition) { - parentBlock.setBaseStyle("position", "relative"); - } - const parentElementBounds = parentElement.getBoundingClientRect(); - let x = (ev.x - parentElementBounds.left) / canvasProps.scale; - let y = (ev.y - parentElementBounds.top) / canvasProps.scale; - const parentWidth = getNumberFromPx(getComputedStyle(parentElement).width); - const parentHeight = getNumberFromPx(getComputedStyle(parentElement).height); - - const childBlock = parentBlock.addChild(child); - childBlock.setBaseStyle("position", "absolute"); - childBlock.setBaseStyle("top", addPxToNumber(y)); - childBlock.setBaseStyle("left", addPxToNumber(x)); - if (store.mode === "container" || store.mode === "repeater") { - const colors = ["#ededed", "#e2e2e2", "#c7c7c7"]; - childBlock.setBaseStyle("background", colors[counter % colors.length]); - counter++; - } - - const mouseMoveHandler = (mouseMoveEvent: MouseEvent) => { - if (store.mode === "text") { - return; - } else { - mouseMoveEvent.preventDefault(); - let width = (mouseMoveEvent.clientX - initialX) / canvasProps.scale; - let height = (mouseMoveEvent.clientY - initialY) / canvasProps.scale; - width = clamp(width, 0, parentWidth); - height = clamp(height, 0, parentHeight); - const setFullWidth = width === parentWidth; - childBlock.setBaseStyle("width", setFullWidth ? "100%" : addPxToNumber(width)); - childBlock.setBaseStyle("height", addPxToNumber(height)); - } - }; - useEventListener(document, "mousemove", mouseMoveHandler); - useEventListener( - document, - "mouseup", - () => { - document.removeEventListener("mousemove", mouseMoveHandler); - parentBlock.setBaseStyle("position", parentOldPosition || "static"); - childBlock.setBaseStyle("position", "static"); - childBlock.setBaseStyle("top", "auto"); - childBlock.setBaseStyle("left", "auto"); - setTimeout(() => { - store.mode = "select"; - }, 50); - if (store.mode === "text") { - canvasHistory.value?.resume(true); - store.editableBlock = childBlock; - return; - } - if (parentBlock.isGrid()) { - childBlock.setStyle("width", "auto"); - childBlock.setStyle("height", "100%"); - } else { - if (getNumberFromPx(childBlock.getStyle("width")) < 100) { - childBlock.setBaseStyle("width", "100%"); - } - if (getNumberFromPx(childBlock.getStyle("height")) < 100) { - childBlock.setBaseStyle("height", "200px"); - } - } - canvasHistory.value?.resume(true); - }, - { once: true }, - ); - } - }); - - useEventListener(container, "mousedown", (ev: MouseEvent) => { - if (store.mode === "move") { - container.style.cursor = "grabbing"; - const initialX = ev.clientX; - const initialY = ev.clientY; - const initialTranslateX = canvasProps.translateX; - const initialTranslateY = canvasProps.translateY; - const mouseMoveHandler = (mouseMoveEvent: MouseEvent) => { - mouseMoveEvent.preventDefault(); - const diffX = (mouseMoveEvent.clientX - initialX) / canvasProps.scale; - const diffY = (mouseMoveEvent.clientY - initialY) / canvasProps.scale; - canvasProps.translateX = initialTranslateX + diffX; - canvasProps.translateY = initialTranslateY + diffY; - }; - useEventListener(document, "mousemove", mouseMoveHandler); - useEventListener( - document, - "mouseup", - () => { - document.removeEventListener("mousemove", mouseMoveHandler); - container.style.cursor = "grab"; - }, - { once: true }, - ); - ev.stopPropagation(); - ev.preventDefault(); - } - }); - - useEventListener(document, "keydown", (ev: KeyboardEvent) => { - if (isTargetEditable(ev) || selectedBlocks.value.length !== 1) return; - - const selectedBlock = selectedBlocks.value[0]; - - const selectBlock = (block: Block | null) => { - if (block) store.selectBlock(block, null, true, true); - return !!block; - }; - - const selectSibling = (direction: "previous" | "next", fallback: () => void) => { - selectBlock(selectedBlock.getSiblingBlock(direction)) || fallback(); - }; - - const selectParent = () => selectBlock(selectedBlock.getParentBlock()); - - const selectFirstChild = () => selectBlock(selectedBlock.children[0]); - - const selectNextSiblingOrParent = () => { - let sibling = selectedBlock.getSiblingBlock("next"); - let parentBlock = selectedBlock.getParentBlock(); - while (!sibling && parentBlock) { - sibling = parentBlock.getSiblingBlock("next"); - parentBlock = parentBlock.getParentBlock(); - } - selectBlock(sibling); - }; - - const selectLastChildInTree = (block: Block) => { - let currentBlock = block; - while (store.activeLayers?.isExpandedInTree(currentBlock)) { - const lastChild = currentBlock.getLastChild() as Block; - if (!lastChild) break; - currentBlock = lastChild; - } - selectBlock(currentBlock); - }; - - switch (ev.key) { - case "ArrowLeft": - store.activeLayers?.isExpandedInTree(selectedBlock) - ? store.activeLayers.toggleExpanded(selectedBlock) - : selectSibling("previous", selectParent); - break; - case "ArrowRight": - selectedBlock.hasChildren() && selectedBlock.isVisible() - ? (store.activeLayers?.toggleExpanded(selectedBlock), selectFirstChild()) - : selectNextSiblingOrParent(); - break; - case "ArrowUp": - selectBlock(selectedBlock.getSiblingBlock("previous")) - ? selectLastChildInTree(selectedBlock.getSiblingBlock("previous") as Block) - : selectParent(); - break; - case "ArrowDown": - store.activeLayers?.isExpandedInTree(selectedBlock) && - selectedBlock.hasChildren() && - selectedBlock.isVisible() - ? selectFirstChild() - : selectNextSiblingOrParent(); - break; - } - }); -} - -const containerBound = reactive(useElementBounding(canvasContainer)); -const canvasBound = reactive(useElementBounding(canvas)); - -const setScaleAndTranslate = async () => { - if (document.readyState !== "complete") { - await new Promise((resolve) => { - window.addEventListener("load", resolve); - }); - } - const paddingX = 300; - const paddingY = 200; - - await nextTick(); - canvasBound.update(); - const containerWidth = containerBound.width; - const canvasWidth = canvasBound.width / canvasProps.scale; - - canvasProps.scale = containerWidth / (canvasWidth + paddingX * 2); - - canvasProps.translateX = 0; - canvasProps.translateY = 0; - await nextTick(); - const scale = canvasProps.scale; - canvasBound.update(); - const diffY = containerBound.top - canvasBound.top + paddingY * scale; - if (diffY !== 0) { - canvasProps.translateY = diffY / scale; - } - canvasProps.settingCanvas = false; -}; - -onMounted(() => { - setScaleAndTranslate(); - const canvasContainerEl = canvasContainer.value as unknown as HTMLElement; - const canvasEl = canvas.value as unknown as HTMLElement; setPanAndZoom(canvasEl, canvasContainerEl, canvasProps); - showBlocks.value = true; + useBlockEventHandlers(); }); -const resetZoom = () => { - canvasProps.scale = 1; - canvasProps.translateX = 0; - canvasProps.translateY = 0; -}; - -const moveCanvas = (direction: "up" | "down" | "right" | "left") => { - if (direction === "up") { - canvasProps.translateY -= 20; - } else if (direction === "down") { - canvasProps.translateY += 20; - } else if (direction === "right") { - canvasProps.translateX += 20; - } else if (direction === "left") { - canvasProps.translateX -= 20; +const handleClick = (ev: MouseEvent) => { + const target = document.elementFromPoint(ev.clientX, ev.clientY); + // hack to ensure if click is on canvas-container + // TODO: Still clears selection if space handlers are dragged over canvas-container + if (target?.classList.contains("canvas-container")) { + clearSelection(); } }; -const zoomIn = () => { - canvasProps.scale = Math.min(canvasProps.scale + 0.1, 10); -}; - -const zoomOut = () => { - canvasProps.scale = Math.max(canvasProps.scale - 0.1, 0.1); -}; +watch( + () => block, + () => { + toggleDirty(true); + }, + { + deep: true, + }, +); watch( () => canvasProps.breakpoints.map((b) => b.visible), @@ -574,248 +239,7 @@ watch( }, ); -function toggleMode(mode: BuilderMode) { - if (!canvasContainer.value) return; - const container = canvasContainer.value as HTMLElement; - if (mode === "text") { - container.style.cursor = "text"; - } else if (["container", "image", "repeater"].includes(mode)) { - container.style.cursor = "crosshair"; - } else if (mode === "move") { - container.style.cursor = "grab"; - } else { - container.style.cursor = "default"; - } -} - -const handleClick = (ev: MouseEvent) => { - const target = document.elementFromPoint(ev.clientX, ev.clientY); - // hack to ensure if click is on canvas-container - // TODO: Still clears selection if space handlers are dragged over canvas-container - if (target?.classList.contains("canvas-container")) { - clearSelection(); - } -}; - -const clearCanvas = () => { - block.value = store.getRootBlock(); -}; - -const getFirstBlock = () => { - return block.value; -}; - -const setRootBlock = (newBlock: Block, resetCanvas = false) => { - block.value = newBlock; - if (canvasHistory.value) { - canvasHistory.value.dispose(); - setupHistory(); - } - if (resetCanvas) { - nextTick(() => { - setScaleAndTranslate(); - toggleDirty(false); - }); - } -}; - -const selectedBlockIds = ref([]) as Ref; -const selectedBlocks = computed(() => { - return selectedBlockIds.value.map((id) => findBlock(id)).filter((b) => b) as Block[]; -}) as Ref; - -const isSelected = (block: Block) => { - return selectedBlockIds.value.includes(block.blockId); -}; - -let maintainTrail = false; - -const selectBlock = (_block: Block, multiSelect = false) => { - if (multiSelect) { - selectedBlockIds.value.push(_block.blockId); - } else { - selectedBlockIds.value.splice(0, selectedBlockIds.value.length, _block.blockId); - } - if (!maintainTrail) { - selectionTrail = []; - } -}; - -const toggleBlockSelection = (_block: Block) => { - if (isSelected(_block)) { - selectedBlockIds.value.splice(selectedBlockIds.value.indexOf(_block.blockId), 1); - } else { - selectBlock(_block, true); - } -}; - -const selectBlockRange = (newSelectedBlock: Block) => { - const lastSelectedBlockId = selectedBlockIds.value[selectedBlockIds.value.length - 1]; - const lastSelectedBlock = findBlock(lastSelectedBlockId); - const lastSelectedBlockParent = lastSelectedBlock?.parentBlock; - if (!lastSelectedBlock || !lastSelectedBlockParent) { - newSelectedBlock.selectBlock(); - return; - } - const lastSelectedBlockIndex = lastSelectedBlock.parentBlock?.children.indexOf(lastSelectedBlock); - const newSelectedBlockIndex = newSelectedBlock.parentBlock?.children.indexOf(newSelectedBlock); - const newSelectedBlockParent = newSelectedBlock.parentBlock; - if (lastSelectedBlockIndex === undefined || newSelectedBlockIndex === undefined) { - return; - } - const start = Math.min(lastSelectedBlockIndex, newSelectedBlockIndex); - const end = Math.max(lastSelectedBlockIndex, newSelectedBlockIndex); - if (lastSelectedBlockParent === newSelectedBlockParent) { - const blocks = lastSelectedBlockParent.children.slice(start, end + 1); - selectedBlockIds.value = selectedBlockIds.value.concat(...blocks.map((b) => b.blockId)); - selectedBlockIds.value = Array.from(new Set(selectedBlockIds.value)); - } -}; - -const clearSelection = () => { - selectedBlockIds.value = []; -}; - -const findBlock = (blockId: string, blocks?: Block[]): Block | null => { - if (!blocks) { - blocks = [getFirstBlock()]; - } - for (const block of blocks) { - if (block.blockId === blockId) { - return block; - } - if (block.children) { - const found = findBlock(blockId, block.children); - if (found) { - return found; - } - } - } - return null; -}; - -const removeBlock = (block: Block) => { - if (block.blockId === "root") { - toast.warning("Warning", { - description: "Cannot delete root block", - }); - return; - } - if (block.isChildOfComponentBlock()) { - toast.warning("Warning", { - description: "Cannot delete block inside component", - }); - return; - } - const parentBlock = block.parentBlock; - if (!parentBlock) { - return; - } - const index = parentBlock.children.indexOf(block); - parentBlock.removeChild(block); - nextTick(() => { - if (parentBlock.children.length) { - const nextSibling = parentBlock.children[index] || parentBlock.children[index - 1]; - if (nextSibling) { - selectBlock(nextSibling); - } - } - }); -}; - -watch( - () => block, - () => { - toggleDirty(true); - }, - { - deep: true, - }, -); - -const toggleDirty = (dirty: boolean | null = null) => { - if (dirty === null) { - isDirty.value = !isDirty.value; - } else { - isDirty.value = dirty; - } -}; - -const scrollBlockIntoView = async (blockToFocus: Block) => { - // wait for editor to render - await new Promise((resolve) => setTimeout(resolve, 100)); - await nextTick(); - if ( - !canvasContainer.value || - !canvas.value || - blockToFocus.isRoot() || - !blockToFocus.isVisible() || - blockToFocus.getParentBlock()?.isSVG() - ) { - return; - } - const container = canvasContainer.value as HTMLElement; - const containerRect = container.getBoundingClientRect(); - const selectedBlock = document.body.querySelector( - `.editor[data-block-id="${blockToFocus.blockId}"][selected=true]`, - ) as HTMLElement; - if (!selectedBlock) { - return; - } - const blockRect = reactive(useElementBounding(selectedBlock)); - // check if block is in view - if ( - blockRect.top >= containerRect.top && - blockRect.bottom <= containerRect.bottom && - blockRect.left >= containerRect.left && - blockRect.right <= containerRect.right - ) { - return; - } - - let padding = 80; - let paddingBottom = 200; - const blockWidth = blockRect.width + padding * 2; - const containerBound = container.getBoundingClientRect(); - const blockHeight = blockRect.height + padding + paddingBottom; - - const scaleX = containerBound.width / blockWidth; - const scaleY = containerBound.height / blockHeight; - const newScale = Math.min(scaleX, scaleY); - - const scaleDiff = canvasProps.scale - canvasProps.scale * newScale; - if (scaleDiff > 0.2) { - return; - } - - if (newScale < 1) { - canvasProps.scale = canvasProps.scale * newScale; - await new Promise((resolve) => setTimeout(resolve, 100)); - await nextTick(); - blockRect.update(); - } - - padding = padding * canvasProps.scale; - paddingBottom = paddingBottom * canvasProps.scale; - - // slide in block from the closest edge of the container - const diffTop = containerRect.top - blockRect.top + padding; - const diffBottom = blockRect.bottom - containerRect.bottom + paddingBottom; - const diffLeft = containerRect.left - blockRect.left + padding; - const diffRight = blockRect.right - containerRect.right + padding; - - if (diffTop > 0) { - canvasProps.translateY += diffTop / canvasProps.scale; - } else if (diffBottom > 0) { - canvasProps.translateY -= diffBottom / canvasProps.scale; - } - - if (diffLeft > 0) { - canvasProps.translateX += diffLeft / canvasProps.scale; - } else if (diffRight > 0) { - canvasProps.translateX -= diffRight / canvasProps.scale; - } -}; +provide("canvasProps", canvasProps); defineExpose({ setScaleAndTranslate, @@ -823,9 +247,9 @@ defineExpose({ moveCanvas, zoomIn, zoomOut, - history: canvasHistory as Ref>, + history, clearCanvas, - getFirstBlock, + getRootBlock, block, setRootBlock, canvasProps, @@ -842,4 +266,35 @@ defineExpose({ removeBlock, selectBlockRange, }); + +function selectBreakpoint(ev: MouseEvent, breakpoint: BreakpointConfig) { + if (isCtrlOrCmd(ev)) { + canvasProps.breakpoints.forEach((bp) => { + bp.visible = bp.device === breakpoint.device; + }); + } else { + breakpoint.visible = !breakpoint.visible; + if (canvasProps.breakpoints.filter((bp) => bp.visible).length === 0) { + breakpoint.visible = true; + } + } + if (breakpoint.visible) { + store.hoveredBreakpoint = breakpoint.device; + store.activeBreakpoint = breakpoint.device; + breakpoint.renderedOnce = true; + } +} + +const renderedBreakpoints = computed(() => canvasProps.breakpoints.filter((bp) => bp.renderedOnce)); + diff --git a/frontend/src/components/BuilderLeftPanel.vue b/frontend/src/components/BuilderLeftPanel.vue index 24085f92..0d113352 100644 --- a/frontend/src/components/BuilderLeftPanel.vue +++ b/frontend/src/components/BuilderLeftPanel.vue @@ -1,12 +1,17 @@