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 ── --%>
PhiaUI Design <%!-- Viewport controls (Phase F) --%>
<%!-- ── Left Panel ── --%>
<%!-- Panel tabs --%>
<%!-- Components panel --%>
{tier}
{comp.name}
<%!-- 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 --%>
Properties — {@selected_node.name}
Slots
Select a node to edit its properties.
<%!-- Code panel --%>
{@generated_code}
<%!-- ── Template Modal (Phase D) ── --%>
""" 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