defmodule PhiaUiDesign.Web.EditorLive do
@moduledoc """
Main LiveView for the PhiaUI Design visual editor.
Orchestrates the entire editor UI:
- **Toolbar** — undo/redo, theme selector, responsive preview, templates, save/load, export
- **Left panel** — component catalog (searchable, grouped by tier) and layer tree
- **Canvas** — real component rendering with selection overlays
- **Right panel** — property inspector for the selected node and real-time code generation
## Architecture
State is held in:
- `Scene` — ETS-backed scene graph (nodes, parent-child relations)
- `Selection` — plain struct tracking selected / hovered node IDs
- `History` — Agent-backed undo/redo command stack
Every user action (insert, delete, update) mutates the Scene ETS
table, pushes a command to History, and recomputes the derived
assigns (`:tree`, `:selected_node`, `:generated_code`).
"""
use Phoenix.LiveView
alias PhiaUiDesign.Canvas.{Scene, History, Selection, Persistence}
alias PhiaUiDesign.Codegen.{HeexEmitter, LiveviewEmitter}
alias PhiaUiDesign.Templates.PageTemplates
alias PhiaUiDesign.Web.ComponentRenderer
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@impl true
def mount(_params, _session, socket) do
scene = Scene.new()
{:ok, history} = History.start_link()
catalog =
try do
PhiaUiDesign.Catalog.CatalogServer.by_tier()
rescue
_ -> []
end
templates = PageTemplates.list()
socket =
socket
|> assign(:scene, scene)
|> assign(:history, history)
|> assign(:selection, Selection.new())
|> assign(:catalog, catalog)
|> assign(:search_query, "")
|> assign(:filtered_catalog, catalog)
|> assign(:selected_node, nil)
|> assign(:generated_code, "")
|> assign(:code_format, :heex)
|> assign(:current_theme, :zinc)
|> assign(:panel, :components)
|> assign(:tree, [])
|> assign(:templates, templates)
|> assign(:show_templates, false)
|> assign(:viewport, :desktop)
|> update_tree()
|> update_code()
{:ok, socket}
end
# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------
@impl true
def render(assigns) do
~H"""
<%!-- ── Toolbar ── --%>
<%!-- ── Left Panel ── --%>
<%!-- Panel tabs --%>
Components
Layers
<%!-- Components panel --%>
<%!-- Layers panel --%>
Empty canvas. Add components from the panel.
<.render_layer_tree tree={@tree} selection={@selection} />
<%!-- ── Canvas ── --%>
Empty Canvas
Click a component on the left panel to add it, or use Templates to start with a page layout.
<.render_canvas_tree tree={@tree} scene={@scene} selection={@selection} />
<%!-- ── Right Panel ── --%>
<%!-- Properties --%>
Select a node to edit its properties.
<%!-- Code panel --%>
<%!-- ── Template Modal (Phase D) ── --%>
{tpl.name}
{tpl.description}
"""
end
# ---------------------------------------------------------------------------
# Canvas tree renderer
# ---------------------------------------------------------------------------
defp render_canvas_tree(assigns) do
~H"""
<.render_canvas_node tree_node={tree_node} scene={@scene} selection={@selection} />
"""
end
defp render_canvas_node(assigns) do
node = assigns.tree_node.node
children = assigns.tree_node.children
selected = Selection.selected?(assigns.selection, node.id)
# Phase A: attempt real component rendering
preview_html =
if node.type == :phia_component and node.module do
case ComponentRenderer.render_to_html(node.module, node.component, node.attrs, node.slots) do
{:ok, html} -> Phoenix.HTML.raw(html)
_ -> nil
end
end
assigns =
assigns
|> assign(:node, node)
|> assign(:children, children)
|> assign(:selected, selected)
|> assign(:preview_html, preview_html)
~H"""
{@preview_html}
<.render_node_content
node={@node}
children={@children}
scene={@scene}
selection={@selection}
/>
"""
end
# PhiaUI component — placeholder fallback
defp render_node_content(%{node: %{type: :phia_component}} = assigns) do
~H"""
<.{@node.component}>
{@node.slots[:inner_block]}
<.render_canvas_node tree_node={child} scene={@scene} selection={@selection} />
"""
end
# HTML element
defp render_node_content(%{node: %{type: :html_element}} = assigns) do
~H"""
<.render_canvas_node tree_node={child} scene={@scene} selection={@selection} />
"""
end
# Text node
defp render_node_content(%{node: %{type: :text}} = assigns) do
~H"""
{@node.text_content}
"""
end
# Frame (grouping container)
defp render_node_content(%{node: %{type: :frame}} = assigns) do
~H"""
<.render_canvas_node tree_node={child} scene={@scene} selection={@selection} />
"""
end
# Fallback
defp render_node_content(assigns) do
~H"""
Unknown node type
"""
end
# ---------------------------------------------------------------------------
# Layer tree renderer
# ---------------------------------------------------------------------------
defp render_layer_tree(assigns) do
~H"""
<.render_layer_node tree_node={tree_node} selection={@selection} depth={0} />
"""
end
defp render_layer_node(assigns) do
node = assigns.tree_node.node
children = assigns.tree_node.children
selected = Selection.selected?(assigns.selection, node.id)
assigns =
assigns
|> assign(:node, node)
|> assign(:children, children)
|> assign(:selected, selected)
~H"""
{layer_icon(@node.type)}
{@node.name}
<.render_layer_node tree_node={child} selection={@selection} depth={@depth + 1} />
"""
end
defp layer_icon(:phia_component), do: "\u25C6"
defp layer_icon(:html_element), do: "\u25A1"
defp layer_icon(:text), do: "T"
defp layer_icon(:frame), do: "\u25A2"
defp layer_icon(_), do: "\u00B7"
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
@impl true
def handle_event("insert_component", %{"name" => name}, socket) do
component_key =
try do
String.to_existing_atom(name)
rescue
ArgumentError -> String.to_atom(name)
end
parent_id = Selection.single_selected(socket.assigns.selection)
default_slots = %{inner_block: default_content(component_key)}
{:ok, node} =
Scene.insert_component(
socket.assigns.scene,
component_key,
attrs: default_attrs(component_key),
slots: default_slots,
parent_id: parent_id
)
History.push(socket.assigns.history, {:insert, node})
socket =
socket
|> assign(:selection, Selection.select(socket.assigns.selection, node.id))
|> update_selected_node()
|> update_tree()
|> update_code()
{:noreply, socket}
end
def handle_event("select_node", %{"id" => id}, socket) do
socket =
socket
|> assign(:selection, Selection.select(socket.assigns.selection, id))
|> update_selected_node()
{:noreply, socket}
end
def handle_event("canvas_click", _params, socket) do
socket =
socket
|> assign(:selection, Selection.clear(socket.assigns.selection))
|> assign(:selected_node, nil)
{:noreply, socket}
end
def handle_event("delete_node", %{"id" => id}, socket) do
case Scene.get_node(socket.assigns.scene, id) do
{:ok, node} ->
History.push(socket.assigns.history, {:delete, node})
Scene.delete_node(socket.assigns.scene, id)
socket =
socket
|> assign(:selection, Selection.clear(socket.assigns.selection))
|> assign(:selected_node, nil)
|> update_tree()
|> update_code()
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_event("update_attr", %{"key" => key, "value" => value}, socket) do
case Selection.single_selected(socket.assigns.selection) do
nil ->
{:noreply, socket}
node_id ->
parsed_value = parse_attr_value(value)
attr_key = safe_to_existing_atom(key)
Scene.update_attrs(socket.assigns.scene, node_id, %{attr_key => parsed_value})
socket =
socket
|> update_selected_node()
|> update_tree()
|> update_code()
{:noreply, socket}
end
end
def handle_event("update_slot", %{"slot" => slot_name, "value" => value}, socket) do
case Selection.single_selected(socket.assigns.selection) do
nil ->
{:noreply, socket}
node_id ->
case Scene.get_node(socket.assigns.scene, node_id) do
{:ok, node} ->
new_slots = Map.put(node.slots, String.to_atom(slot_name), value)
Scene.update_node(socket.assigns.scene, node_id, %{slots: new_slots})
socket =
socket
|> update_selected_node()
|> update_tree()
|> update_code()
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
end
def handle_event("search_components", %{"query" => query}, socket) do
filtered =
if query == "" do
socket.assigns.catalog
else
query_down = String.downcase(query)
socket.assigns.catalog
|> Enum.map(fn {tier, comps} ->
filtered_comps =
Enum.filter(comps, fn c ->
String.contains?(String.downcase(c.name), query_down)
end)
{tier, filtered_comps}
end)
|> Enum.reject(fn {_tier, comps} -> comps == [] end)
end
{:noreply, assign(socket, search_query: query, filtered_catalog: filtered)}
end
def handle_event("set_panel", %{"panel" => panel}, socket) do
{:noreply, assign(socket, :panel, String.to_existing_atom(panel))}
end
def handle_event("set_code_format", %{"format" => format}, socket) do
socket =
socket
|> assign(:code_format, String.to_existing_atom(format))
|> update_code()
{:noreply, socket}
end
def handle_event("change_theme", %{"theme" => theme}, socket) do
{:noreply, assign(socket, :current_theme, String.to_existing_atom(theme))}
end
# Phase D: Template picker
def handle_event("show_templates", _params, socket) do
{:noreply, assign(socket, :show_templates, true)}
end
def handle_event("hide_templates", _params, socket) do
{:noreply, assign(socket, :show_templates, false)}
end
def handle_event("apply_template", %{"key" => key}, socket) do
template_key = safe_to_existing_atom(key)
case PageTemplates.apply_template(socket.assigns.scene, template_key) do
{:ok, _scene} ->
socket =
socket
|> assign(:show_templates, false)
|> assign(:selection, Selection.clear(socket.assigns.selection))
|> assign(:selected_node, nil)
|> update_tree()
|> update_code()
{:noreply, put_flash(socket, :info, "Template applied!")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to apply template.")}
end
end
# Phase F: Responsive preview
def handle_event("set_viewport", %{"size" => size}, socket) do
viewport = safe_to_existing_atom(size)
{:noreply, assign(socket, :viewport, viewport)}
end
# Phase C: Save project
def handle_event("save_project", _params, socket) do
path = default_project_path()
case Persistence.save(socket.assigns.scene, path, theme: socket.assigns.current_theme) do
:ok ->
{:noreply, put_flash(socket, :info, "Saved to #{path}")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Save failed: #{inspect(reason)}")}
end
end
def handle_event("undo", _params, socket) do
case History.undo(socket.assigns.history) do
{:insert, node} ->
Scene.delete_node(socket.assigns.scene, node.id)
socket =
socket
|> assign(:selection, Selection.clear(socket.assigns.selection))
|> assign(:selected_node, nil)
|> update_tree()
|> update_code()
{:noreply, socket}
{:delete, node} ->
Scene.insert(socket.assigns.scene, node)
socket =
socket
|> update_tree()
|> update_code()
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case History.redo(socket.assigns.history) do
{:insert, node} ->
Scene.insert(socket.assigns.scene, node)
socket =
socket
|> update_tree()
|> update_code()
{:noreply, socket}
{:delete, node} ->
Scene.delete_node(socket.assigns.scene, node.id)
socket =
socket
|> assign(:selection, Selection.clear(socket.assigns.selection))
|> assign(:selected_node, nil)
|> update_tree()
|> update_code()
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_event("export_code", _params, socket) do
{:noreply, put_flash(socket, :info, "Code exported! Use Copy to clipboard.")}
end
def handle_event("copy_code", _params, socket) do
# Clipboard write is handled client-side by the CopyClipboard hook
{:noreply, socket}
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp update_tree(socket) do
tree = Scene.to_tree(socket.assigns.scene)
assign(socket, :tree, tree)
end
defp update_selected_node(socket) do
case Selection.single_selected(socket.assigns.selection) do
nil ->
assign(socket, :selected_node, nil)
id ->
case Scene.get_node(socket.assigns.scene, id) do
{:ok, node} -> assign(socket, :selected_node, node)
_ -> assign(socket, :selected_node, nil)
end
end
end
defp update_code(socket) do
code =
case socket.assigns.code_format do
:heex -> HeexEmitter.emit(socket.assigns.scene)
:liveview -> LiveviewEmitter.emit(socket.assigns.scene, "MyAppWeb.DesignedLive")
end
assign(socket, :generated_code, code)
end
defp parse_attr_value("true"), do: true
defp parse_attr_value("false"), do: false
defp parse_attr_value(":" <> atom_str) do
safe_to_existing_atom(atom_str)
end
defp parse_attr_value(value) do
case Integer.parse(value) do
{int, ""} ->
int
_ ->
case Float.parse(value) do
{f, ""} -> f
_ -> value
end
end
end
defp safe_to_existing_atom(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> String.to_atom(str)
end
defp default_attrs(:button), do: %{variant: :default, size: :default}
defp default_attrs(:badge), do: %{variant: :default}
defp default_attrs(:input), do: %{type: "text", placeholder: "Enter text..."}
defp default_attrs(_), do: %{}
defp default_content(:button), do: "Button"
defp default_content(:badge), do: "Badge"
defp default_content(:card), do: nil
defp default_content(name) do
name
|> to_string()
|> String.replace("_", " ")
|> String.capitalize()
end
defp viewport_style(:desktop), do: ""
defp viewport_style(:tablet), do: "max-width:768px;"
defp viewport_style(:mobile), do: "max-width:375px;"
defp viewport_style(_), do: ""
defp default_project_path do
Path.join([File.cwd!(), "priv", "phiaui_design", "projects", "untitled.phia.json"])
end
end