diff --git a/builder/builder/doctype/builder_page/builder_page.json b/builder/builder/doctype/builder_page/builder_page.json
index 763e6ae27..c0dec7947 100644
--- a/builder/builder/doctype/builder_page/builder_page.json
+++ b/builder/builder/doctype/builder_page/builder_page.json
@@ -23,11 +23,14 @@
"section_break_ujsp",
"blocks",
"draft_blocks",
+ "tiles",
+ "draft_tiles",
"scripting_tab",
"page_data_script",
"head_html",
"body_html",
"client_scripts",
+ "include_alpinejs",
"settings_tab",
"section_break_shab",
"preview",
@@ -245,6 +248,27 @@
"mandatory_depends_on": "is_standard",
"no_copy": 1
},
+ {
+ "fieldname": "tiles",
+ "fieldtype": "Table",
+ "label": "Tiles",
+ "options": "Builder Tile",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "description": "AlpineJS is included by default in pages with Tiles.",
+ "fieldname": "include_alpinejs",
+ "fieldtype": "Check",
+ "label": "Include AlpineJS"
+ },
+ {
+ "fieldname": "draft_tiles",
+ "fieldtype": "Table",
+ "label": "Draft Tiles",
+ "options": "Builder Tile",
+ "read_only": 1
+ },
{
"fieldname": "published_at",
"fieldtype": "Datetime",
diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py
index bbee74d1c..3bd40175e 100644
--- a/builder/builder/doctype/builder_page/builder_page.py
+++ b/builder/builder/doctype/builder_page/builder_page.py
@@ -40,6 +40,7 @@
get_template_assets_folder_path,
is_component_used,
sanitize_style_value,
+ script_hash,
split_styles,
)
@@ -102,6 +103,7 @@ class BuilderPage(WebsiteGenerator):
from builder.builder.doctype.builder_page_client_script.builder_page_client_script import (
BuilderPageClientScript,
)
+ from builder.builder.doctype.builder_tile.builder_tile import BuilderTile
app: DF.Literal[None]
authenticated_access: DF.Check
@@ -111,9 +113,11 @@ class BuilderPage(WebsiteGenerator):
client_scripts: DF.TableMultiSelect[BuilderPageClientScript]
disable_indexing: DF.Check
draft_blocks: DF.LongText | None
+ draft_tiles: DF.Table[BuilderTile]
dynamic_route: DF.Check
favicon: DF.AttachImage | None
head_html: DF.Code | None
+ include_alpinejs: DF.Check
is_standard: DF.Check
is_template: DF.Check
language: DF.Data | None
@@ -127,6 +131,7 @@ class BuilderPage(WebsiteGenerator):
published: DF.Check
published_at: DF.Datetime | None
route: DF.Data | None
+ tiles: DF.Table[BuilderTile]
# end: auto-generated types
def onload(self):
@@ -230,6 +235,7 @@ def publish(self):
if self.draft_blocks:
self.blocks = self.draft_blocks
self.draft_blocks = None
+ self.draft_tiles = []
self.save()
capture("builder_page_published", "builder")
frappe.enqueue_doc(
@@ -248,9 +254,67 @@ def unpublish(self):
self.save()
capture("builder_page_unpublished", "builder")
+
+ @frappe.whitelist()
+ def save_tiles(self, for_draft: bool = False):
+ _, _, _, _, tiles = get_block_html((self.draft_blocks if for_draft else self.blocks) or "[]")
+ if not for_draft:
+ self.tiles = []
+ for tile_id, tile_block in tiles.items():
+ tile_doc = self.append("tiles", {})
+ tile_doc.block_id = tile_id
+ tile_doc.blocks = frappe.as_json(tile_block)
+ else:
+ self.draft_tiles = []
+ for tile_id, tile_block in tiles.items():
+ tile_doc = self.append("draft_tiles", {})
+ tile_doc.block_id = tile_id
+ tile_doc.blocks = frappe.as_json(tile_block)
+ self.save()
+
+ @frappe.whitelist()
+ def save_tiles(self, for_draft: bool = False):
+ _, _, _, _, tiles = get_block_html((self.draft_blocks if for_draft else self.blocks) or "[]")
+ if not for_draft:
+ self.tiles = []
+ for tile_id, tile_block in tiles.items():
+ tile_doc = self.append("tiles", {})
+ tile_doc.block_id = tile_id
+ tile_doc.blocks = frappe.as_json(tile_block)
+ else:
+ self.draft_tiles = []
+ for tile_id, tile_block in tiles.items():
+ tile_doc = self.append("draft_tiles", {})
+ tile_doc.block_id = tile_id
+ tile_doc.blocks = frappe.as_json(tile_block)
+ self.save()
+
def get_context(self, context):
- # delete default favicon
- del context.favicon
+ # Handle tile partial-rendering requests
+
+ if frappe.form_dict and frappe.form_dict.get("render_tile"):
+ context["template"] = "templates/generators/tile.html"
+
+ tiles = self.draft_tiles if getattr(frappe.local.request, "for_preview", None) else self.tiles
+
+ block_id = frappe.form_dict.get("block_id")
+ block_data = frappe.parse_json(frappe.form_dict.get("block_data")) or {}
+ props = frappe.form_dict.get("props") or {}
+
+ for tile in tiles:
+ if tile.block_id == block_id:
+ content, _, _, _, _ = get_block_html(tile.blocks, for_tile=True)
+ context.__content = content
+ context.update({"block_data": block_data, "props": props, "has_tile": True})
+ context["__content"] = render_template(context.__content, context)
+ break
+ else:
+ frappe.throw(f"Tile with block_id {block_id} not found", frappe.DoesNotExistError)
+ return
+
+ # Handle normal page rendering
+
+ del context.favicon # delete default favicon
context.disable_indexing = self.disable_indexing
context.preview = getattr(getattr(frappe.local, "request", None), "for_preview", None)
@@ -271,7 +335,7 @@ def get_context(self, context):
if context.preview and self.draft_blocks:
blocks = self.draft_blocks
- content, style, fonts, has_block_script = get_block_html(blocks)
+ content, style, fonts, has_block_script, tiles = get_block_html(blocks)
if self.dynamic_route or page_data or has_block_script:
context.no_cache = 1
@@ -280,6 +344,7 @@ def get_context(self, context):
context.fonts = fonts
context.__content = content
context.style = render_template(style, page_data)
+ context.has_tile = len(tiles) > 0
context.editor_link = f"/{builder_path}/page/{self.name}"
if frappe.form_dict and self.dynamic_route:
query_string = "&".join(
@@ -297,6 +362,8 @@ def get_context(self, context):
else:
context.base_url = frappe.utils.get_url(self.route)
+ context.include_alpinejs = self.include_alpinejs
+
context.update(page_data)
self.set_style_and_script(context)
@@ -525,12 +592,13 @@ def get_block_data(
return block_data
-def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]:
+def get_block_html(blocks: str | list, for_tile: bool = False) -> tuple[str, str, dict, bool]:
"""
Main entry point for converting blocks to HTML.
#### Args:
blocks: JSON string or list of block dictionaries
+ for_tile: Whether to render blocks for a tile
#### Returns:
Tuple of (`html_content`, `css_styles`, `font_map`, `has_block_script`)
@@ -552,6 +620,8 @@ def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]:
"standard_props_stack": {}, # prop_name -> list of prop_info
"global_script_tag": soup.new_tag("script"),
"used_block_scripts": set(),
+ "tile_blocks": {}, # block_id -> block json
+ "for_tile": for_tile,
}
html_parts = []
@@ -565,16 +635,28 @@ def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]:
tag = build_tag(block, shared_state)
# Add global script to the top
- tag.insert(0, shared_state["global_script_tag"])
+ if not for_tile:
+ tag.insert(0, shared_state["global_script_tag"])
- html = wrap_html_with_context(str(tag), block_context)
+ html = wrap_html_with_context(str(tag), block_context, for_tile, block.get("blockId", ""))
+ # Write html to a file for debugging
+ with open("output.html", "w") as f:
+ f.write(html)
html_parts.append(html)
- return "".join(html_parts), str(style_tag), font_map, shared_state["has_block_script"]
+ return (
+ "".join(html_parts),
+ str(style_tag),
+ font_map,
+ shared_state["has_block_script"],
+ shared_state["tile_blocks"],
+ )
def build_tag(
- block: dict, state: dict, data_key: dict | None = None, ancestor_font: str | None = None
+ block: dict,
+ state: dict,
+ data_key: dict | None = None,
) -> bs.Tag:
"""
Transforms a single block to an HTML tag.
@@ -582,6 +664,10 @@ def build_tag(
#### Returns:
BeautifulSoup tag element
"""
+ is_tile = block.get("isTile", False)
+
+ if is_tile:
+ state["tile_blocks"][block.get("blockId")] = copy.deepcopy(block)
props = process_block_props(block, data_key, state["standard_props_stack"])
@@ -603,12 +689,17 @@ def build_tag(
tag = create_html_tag(block, state, ancestor_font=resolved_font)
+ if is_tile:
+ tag["data-tile-id"] = block.get("blockId")
+
if is_repeater_block(block):
render_repeater_children(tag, block, data_key, state, ancestor_font=resolved_font)
else:
render_children(tag, block, data_key, state, ancestor_font=resolved_font)
attach_client_script(tag, block, state)
+ if is_tile:
+ attach_client_data(tag, block, state)
# Add body scripts for body element
effective_element = block.get("originalElement") or block.get("element")
@@ -702,7 +793,7 @@ def get_dynamic_props_template(
) -> str:
"""Get a Jinja template reference for dynamic properties."""
if comes_from == "blockDataScript":
- key = jinja_safe_key(f"block.{prop_value}")
+ key = jinja_safe_key(f"block_data.{prop_value}")
elif comes_from == "props":
key = jinja_safe_key(f"props.{prop_value}")
else: # dataScript
@@ -791,7 +882,7 @@ def build_tag_classes(block: dict, state: dict, ancestor_font: str | None = None
def generate_and_apply_styles(block: dict, state: dict, ancestor_font: str | None = None) -> str:
"""Generate a unique style class and append all styles to the style tag."""
- style_class = f"fb-{frappe.generate_hash(length=8)}"
+ style_class = f"fb-{block.get('blockId')}"
style_tag = state["style_tag"]
font_map = state["font_map"]
@@ -914,8 +1005,8 @@ def get_loop_info(block: dict, data_key: dict | None, props_stack: dict) -> dict
elif comes_from == "blockDataScript":
return {
- "loop_var": "block",
- "iterator_key": jinja_safe_key(f"block.{iterator_key}"),
+ "loop_var": "block_data",
+ "iterator_key": jinja_safe_key(f"block_data.{iterator_key}"),
"data_key": data_key,
}
@@ -972,13 +1063,23 @@ def get_visibility_condition_key(block: dict, data_key: dict | None) -> str | No
if comes_from == "props":
return jinja_safe_key(f"props.{key}")
elif comes_from == "blockDataScript":
- return jinja_safe_key(f"block.{key}")
+ return jinja_safe_key(f"block_data.{key}")
else: # dataScript
if data_key:
return f"{extract_data_key(data_key)}.{key}"
return key
+def attach_client_data(tag: bs.Tag, block: dict, state: dict):
+ tag.attrs["x-data"] = f"block('{block.get('blockId')}')"
+ script_tag = state["soup"].new_tag("script", type="application/json")
+ script_tag.string = (
+ '{ "block_data": {{ block_data | to_safe_json }}, "props": {{ props | to_safe_json }} }'
+ )
+ script_tag.attrs["data-block-data"] = block.get("blockId")
+ tag.append(script_tag)
+
+
def attach_client_script(tag: bs.Tag, block: dict, state: dict):
"""Attach client-side JavaScript to the block."""
script = block.get("blockClientScript")
@@ -988,36 +1089,48 @@ def attach_client_script(tag: bs.Tag, block: dict, state: dict):
# Generate unique identifier for the script
script_unique_id = block.get("blockId")
if block.get("isBlockClientScriptOverridden"):
- script_unique_id = frappe.generate_hash(length=8)
+ script_unique_id = script_hash(script, length=8)
- # Add global function definition (only once)
- if script_unique_id not in state["used_block_scripts"]:
- state["global_script_tag"].append(
- f"function client_script_{script_unique_id}(props, block_data) {{{script}}}\n"
- )
- state["used_block_scripts"].add(script_unique_id)
+ if not state["for_tile"]:
+ # Add global function definition (only once)
+ if script_unique_id not in state["used_block_scripts"]:
+ state["global_script_tag"].append(
+ f"function client_script_{script_unique_id}(props, block_data) {{{script}}}\n"
+ )
+ state["used_block_scripts"].add(script_unique_id)
# Add data attribute for selecting this specific block
tag.attrs["data-block-uid"] = "{{ unique_hash }}"
# Add local script to call the function
local_script = state["soup"].new_tag("script")
- local_script.string = (
+ local_script["defer"] = "defer"
+ script_content = (
+ f"const element = document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]');"
f"(client_script_{script_unique_id}).call("
- f"document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]'), "
- f"{{{{ props | to_safe_json }}}}, "
- f"{{{{ block.block_data | to_safe_json }}}}"
+ f" element, "
+ f" {{{{ props | to_safe_json }}}}, "
+ f" {{{{ {jinja_safe_key('block_data.block_data')} | to_safe_json }}}}"
f");"
)
- tag.append(local_script)
+ local_script.string = (
+ f"{{% if has_tile or include_alpinejs %}}"
+ f"function exec() {{ {script_content} }}"
+ f"if (window.Alpine) {{ exec() }} else {{ document.addEventListener('alpine:initialized', exec) }}"
+ f"{{% else %}}"
+ f"{{ {script_content} }}"
+ f"{{% endif %}}"
+ )
+ tag.insert(0, local_script)
def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict):
"""Append child tag with proper Jinja context wrapping."""
# Generate unique hash for this block instance
# This is unique for each block irrespective of loops or components
- parent.append("{% with unique_hash = (loop.index if loop is defined else 0) | hash %}")
- parent.append(f"{{% with block_id = '{context['block_id']}' %}}")
+ parent.append(
+ f"{{% with unique_hash = '{context.get('block_id', '')}' ~ '_' ~ (loop.index if loop is defined else 0) %}}"
+ )
if context.get("default_props"):
props_str = ", ".join([f"'{var}': {var}" for var in context["default_props"]])
@@ -1033,7 +1146,7 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict):
if context.get("block_data_script"):
escaped_script = escape_single_quotes(context["block_data_script"])
parent.append(
- f"{{% with block = block | execute_script_and_combine('{escaped_script}', props, block_id) %}}"
+ f"{{% with block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}"
)
if context.get("visibility_key"):
@@ -1105,7 +1218,7 @@ def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key:
if comes_from == "props":
return jinja_safe_key(f"props.{original_key}")
elif comes_from == "blockDataScript":
- return jinja_safe_key(f"block.{original_key}")
+ return jinja_safe_key(f"block_data.{original_key}")
else: # dataScript
key = dynamic_value_doc.get("key")
if data_key:
@@ -1114,7 +1227,7 @@ def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key:
return key
-def wrap_html_with_context(html: str, context: dict) -> str:
+def wrap_html_with_context(html: str, context: dict, for_tile: bool = False, tile_block_id: str = "") -> str:
"""
Wrap HTML with Jinja context variables.
@@ -1126,14 +1239,17 @@ def wrap_html_with_context(html: str, context: dict) -> str:
script_escaped = escape_single_quotes(context.get("block_data_script") or "")
html = (
- f"{{% with block = {{}} | execute_script_and_combine('{script_escaped}', {all_props_literal}, 'root') %}}"
+ f"{{% with block_data = {'block_data' if for_tile else '{}'} %}}"
+ f"{{% with block_data = block_data | execute_block_data_script('{script_escaped}', {all_props_literal}) %}}"
f"{html}"
f"{{% endwith %}}"
+ f"{{% endwith %}}"
)
# Set props contexts
html = f"{{% with props = {all_props_literal} %}}{html}{{% endwith %}}"
html = f"{{% with passed_down_props = {passed_down_literal} %}}{html}{{% endwith %}}"
+ html = f"{{% with unique_hash = '{tile_block_id if for_tile else 'root'}' %}}{html}{{% endwith %}}"
return html
diff --git a/builder/builder/doctype/builder_tile/__init__.py b/builder/builder/doctype/builder_tile/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/builder/builder/doctype/builder_tile/builder_tile.json b/builder/builder/doctype/builder_tile/builder_tile.json
new file mode 100644
index 000000000..9ed1705da
--- /dev/null
+++ b/builder/builder/doctype/builder_tile/builder_tile.json
@@ -0,0 +1,45 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2026-04-15 09:48:15.518085",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "block_id",
+ "blocks"
+ ],
+ "fields": [
+ {
+ "fieldname": "block_id",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Block Id",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "blocks",
+ "fieldtype": "Long Text",
+ "in_list_view": 1,
+ "label": "Blocks",
+ "read_only": 1,
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-04-15 09:48:15.518085",
+ "modified_by": "Administrator",
+ "module": "Builder",
+ "name": "Builder Tile",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/builder/builder/doctype/builder_tile/builder_tile.py b/builder/builder/doctype/builder_tile/builder_tile.py
new file mode 100644
index 000000000..585c0719e
--- /dev/null
+++ b/builder/builder/doctype/builder_tile/builder_tile.py
@@ -0,0 +1,24 @@
+# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BuilderTile(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ block_id: DF.Data
+ blocks: DF.LongText
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/builder/hooks.py b/builder/hooks.py
index 49c80bdce..f2149e34a 100644
--- a/builder/hooks.py
+++ b/builder/hooks.py
@@ -57,7 +57,7 @@
jinja = {
"filters": [
"builder.utils.combine",
- "builder.utils.execute_script_and_combine",
+ "builder.utils.execute_block_data_script",
"builder.utils.hash",
"builder.utils.to_safe_json",
],
diff --git a/builder/templates/generators/tile.html b/builder/templates/generators/tile.html
new file mode 100644
index 000000000..08c9799ad
--- /dev/null
+++ b/builder/templates/generators/tile.html
@@ -0,0 +1,3 @@
+{% block content %}
+ {{ __content }}
+{% endblock %}
\ No newline at end of file
diff --git a/builder/templates/generators/webpage_scripts.html b/builder/templates/generators/webpage_scripts.html
index 0739dd3b0..e42174bfe 100644
--- a/builder/templates/generators/webpage_scripts.html
+++ b/builder/templates/generators/webpage_scripts.html
@@ -1,4 +1,14 @@
{% block script %}
+
+{% if has_tile or include_alpinejs %}
+
+
+
+{% endif %}
{%- if scripts -%}
{% for script_path in scripts %}
@@ -10,7 +20,7 @@
{% if enable_view_tracking and not preview %}
{% endif %}
{% if not preview %}
diff --git a/builder/utils.py b/builder/utils.py
index 82ae61ef5..d993bc914 100644
--- a/builder/utils.py
+++ b/builder/utils.py
@@ -1,3 +1,4 @@
+import hashlib
import inspect
import os
import re
@@ -680,7 +681,7 @@ def get_export_paths(app_path, export_name):
}
-def to_dict_with_fallback(obj):
+def safe_dict_conversion(obj):
try:
return frappe._dict(obj)
except TypeError:
@@ -695,24 +696,28 @@ def combine(a, b):
return b
if b is None:
return a
- res = to_dict_with_fallback(a)
- res.update(to_dict_with_fallback(b))
+ res = safe_dict_conversion(a)
+ res.update(safe_dict_conversion(b))
return res
def hash(s):
- return f"{frappe.generate_hash(length=6)}-{s}"
+ return f"{frappe.generate_hash(length=6)}_{s}"
+
+
+def script_hash(js_code, length=8):
+ return hashlib.sha256(js_code.encode()).hexdigest()[:length]
def to_safe_json(data):
return frappe.as_json(data or {})
-def execute_script_and_combine(prev_block_data, block_data_script, props, block_id):
+def execute_block_data_script(prev_block_data, block_data_script, props):
props = frappe._dict(frappe.parse_json(props or "{}"))
block_data = frappe._dict()
- _locals = dict(block=to_dict_with_fallback(prev_block_data or {}), props=props)
- execute_script(unescape_html(block_data_script), _locals, f"block_script_for_{block_id}")
+ _locals = dict(block=safe_dict_conversion(prev_block_data or {}), props=props)
+ execute_script(unescape_html(block_data_script), _locals, "sample")
block_data.update(_locals["block"])
return combine(prev_block_data, block_data)
diff --git a/frontend/src/block.ts b/frontend/src/block.ts
index 7d8c22362..e11164f40 100644
--- a/frontend/src/block.ts
+++ b/frontend/src/block.ts
@@ -74,6 +74,7 @@ class Block implements BlockOptions {
// @ts-expect-error
referenceComponent: Block | null;
customAttributes: BlockAttributeMap;
+ isTile: boolean;
constructor(options: BlockOptions) {
const componentStore = useComponentStore();
this.element = options.element;
@@ -83,6 +84,7 @@ class Block implements BlockOptions {
this.isChildOfComponent = options.isChildOfComponent;
this.referenceBlockId = options.referenceBlockId;
this.parentBlock = options.parentBlock || null;
+ this.isTile = Boolean(options.isTile);
if (this.extendedFromComponent) {
componentStore.loadComponent(this.extendedFromComponent);
}
@@ -1033,6 +1035,15 @@ class Block implements BlockOptions {
setBlockProps(props: BlockProps) {
this.props = props;
}
+ getIsTile(): boolean {
+ if (this.isExtendedFromComponent()) {
+ return !!this.referenceComponent?.isTile;
+ }
+ return this.isTile;
+ }
+ setIsTile(val: boolean){
+ this.isTile = val;
+ }
}
function extendWithComponent(
diff --git a/frontend/src/builder.d.ts b/frontend/src/builder.d.ts
index f8c07c314..df9cc7fe7 100644
--- a/frontend/src/builder.d.ts
+++ b/frontend/src/builder.d.ts
@@ -53,6 +53,7 @@ declare interface BlockOptions {
children?: Array;
dynamicValues?: Array;
draggable?: boolean;
+ isTile?: boolean;
editorConfig?: BlockEditorConfig;
[key: string]: any;
}
diff --git a/frontend/src/components/BlockContextMenu.vue b/frontend/src/components/BlockContextMenu.vue
index d4e2ce3e2..75da8a09f 100644
--- a/frontend/src/components/BlockContextMenu.vue
+++ b/frontend/src/components/BlockContextMenu.vue
@@ -13,6 +13,7 @@ import NewComponent from "@/components/Modals/NewComponent.vue";
import useBuilderStore from "@/stores/builderStore";
import useCanvasStore from "@/stores/canvasStore";
import useComponentStore from "@/stores/componentStore";
+import blockController from "@/utils/blockController";
import getBlockTemplate from "@/utils/blockTemplate";
import { confirm, detachBlockFromComponent, getBlockCopy, triggerCopyEvent } from "@/utils/helpers";
import { useStorage } from "@vueuse/core";
@@ -259,6 +260,26 @@ const contextMenuOptions: ContextMenuOption[] = [
condition: () => !block.value.isExtendedFromComponent() && Boolean(window.is_developer_mode),
disabled: () => builderStore.readOnlyMode,
},
+ {
+ label: "Convert to Tile",
+ condition: () =>
+ !blockController.multipleBlocksSelected() &&
+ !blockController.isRoot() &&
+ !blockController.getSelectedBlocks()[0].getIsTile(),
+ action: () => {
+ blockController.getSelectedBlocks()[0].setIsTile(true);
+ },
+ },
+ {
+ label: "Revert to Block",
+ condition: () =>
+ !blockController.multipleBlocksSelected() &&
+ !blockController.isRoot() &&
+ blockController.getSelectedBlocks()[0].getIsTile(),
+ action: () => {
+ blockController.getSelectedBlocks()[0].setIsTile(false);
+ },
+ },
{
label: "Save As Component",
action: () => (showNewComponentDialog.value = true),
diff --git a/frontend/src/components/BlockLayers.vue b/frontend/src/components/BlockLayers.vue
index 380fe4cb3..4223ccf9c 100644
--- a/frontend/src/components/BlockLayers.vue
+++ b/frontend/src/components/BlockLayers.vue
@@ -64,7 +64,7 @@
},
]"
aria-hidden="true"
- v-if="!Boolean(element.extendedFromComponent) && !showCodeIcon(element)" />
+ v-if="!Boolean(element.extendedFromComponent) && !showCodeIcon(element) && !element.getIsTile()" />
+
{
@@ -142,6 +151,8 @@ import { nextTick, ref, watch } from "vue";
import draggable from "vuedraggable";
import BlockLayers from "./BlockLayers.vue";
import BlocksIcon from "./Icons/Blocks.vue";
+// @ts-ignore
+import TilesIcon from "~icons/lucide/blocks";
type LayerInstance = InstanceType;
diff --git a/frontend/src/components/Settings/PageCode.vue b/frontend/src/components/Settings/PageCode.vue
index 71ce45e83..29be5f094 100644
--- a/frontend/src/components/Settings/PageCode.vue
+++ b/frontend/src/components/Settings/PageCode.vue
@@ -20,6 +20,16 @@
class="shrink-0"
@update:modelValue="pageStore.updateActivePage('body_html', $event)"
:showLineNumbers="true">
+ {
+ pageStore.updateActivePage('include_alpinejs', val);
+ }
+ " />