Inline edit

GitHub

I am an admin

Flip this to reveal the per-block editing toolbar. It only changes what LiveView renders — the island is mounted on demand.

Welcome to KeenSpace

KeenSpace lets your team ship autonomous Svelte islands into Phoenix. Each app mounts itself, talks to the server, and stays simple and fast.

Edit this text today. An admin can open the editor right on the page, change the words, and translate everything for a global team.

Editing where the words live

The blocks above are rendered by LiveView, not the island. Toggle admin and a floating toolbar appears over each block. Clicking the pencil (or the translate icon) mounts the prose island over that one block. It's one Svelte app with two components — a TipTap editor and a translator — chosen by a mode prop. When you save, the island pushes the new HTML back over live, LiveView updates the block and unmounts the overlay.

The floating toolbar

The pencil/translate chip over each block is pure LiveView + CSS — no JavaScript, no island. Three independent decisions govern it:

Existence — server

An :if guard on @admin (and "not editing this block") means LiveView only writes the toolbar into the HTML when admin is on. In read mode it isn't in the DOM at all.

Appearance — CSS

Even when it exists it's hidden until you hover the block. The wrapper is a Tailwind group, so group-hover:flex reveals the chip on hover — no server round-trip.

Action — phx-click

Each button carries phx-click (edit_block or translate_block) plus phx-value-id, so the click sends the event and the block id back to LiveView.

The handler records which block and which tool in @editing. That re-render hides the toolbar (editing? is now true) and mounts the prose island overlay in its place — the editor or the translator, picked by mode. That overlay is just a sibling <.app name="prose">no registration: the builder auto-discovers local apps under assets/apps/ and serves /apps/prose/main.mjs (config-based registration is only for external/CDN bundles). Crucially open/3 re-checks admin on the server: the toggle and the CSS are only UX, the event handler is the real boundary.

# inline_edit_live.ex — toolbar + island are two mutually-exclusive siblings.
<div :for={block <- @blocks} class="group relative ...">   # relative anchors both; group enables hover
  <div class="prose ...">{raw(block.html)}</div>

  # 1. The toolbar — shown when admin is on and this block ISN'T being edited.
  #    `hidden group-hover:flex` reveals it on hover (pure CSS, no round-trip).
  <div :if={@admin and not editing?(@editing, block.id)}
       class="absolute -top-3 right-2 hidden group-hover:flex ...">
    <button phx-click="edit_block"      phx-value-id={block.id}>✎  pencil</button>
    <button phx-click="translate_block" phx-value-id={block.id}>🌐 translate</button>
  </div>

  # 2. The island — mounted only WHILE this block is edited (the mirror guard).
  #    <.app> is the mount point; a LOCAL app needs no registration — the
  #    builder discovers apps/prose/ and serves /apps/prose/main.mjs.
  <div :if={editing?(@editing, block.id)} class="absolute inset-x-0 -top-2 z-20">
    <.app name="prose" id={"prose-#{block.id}-#{@editing.mode}"}
      props={%{block: block, mode: @editing.mode, languages: Content.languages()}} />
  </div>
</div>

# The click flips @editing, which swaps sibling 1 (toolbar) for sibling 2 (island).
def handle_event("edit_block", %{"id" => id}, socket), do: {:noreply, open(socket, id, "edit")}

defp open(socket, id, mode) do
  # Re-check admin on the SERVER — the toggle + CSS are only UX; this is the boundary.
  if socket.assigns.admin and Content.get(socket.assigns.blocks, id),
    do: assign(socket, editing: %{id: id, mode: mode}),
    else: socket
end

The round-trip, both directions

  1. LiveView → island (props). The block's current HTML, the mode, and the language list are handed in as props when the overlay is rendered.
  2. Island edits locally. TipTap owns its own DOM — that's why the mount sits under phx-update="ignore": LiveView renders whether the island exists, never what's inside it.
  3. Island → LiveView (live.pushEvent). "Save" pushes save_block for the instant update; the translator first pushes translate_text and gets a reply, then saves on accept.
  4. Durable write over api. A LiveView can't write the Plug session itself, so the island also POSTs the HTML to InlineEditController, which stores it in the session. mount seeds from there — so the edit survives a reload, per-user, with no shared store.

Both sides, in code

1 · The island pushes its result over live

// prose/js/App.svelte — instant update over `live`, durable write over `api`
// (a LiveView can't write the session itself, so the island persists it).
async function persist(html) {
  live?.pushEvent("save_block", { id: block.id, html });     // instant, in-session
  await api?.post("/inline-edit/blocks", { id: block.id, html }); // durable (session)
}

2 · LiveView mounts it on demand and owns persistence

# inline_edit_live.ex — seed from the session so edits survive a reload; the
# instant `save_block` update leaves the overlay open until the island's POST
# to InlineEditController has persisted the same HTML into the session.
def mount(_params, session, socket) do
  overrides = Map.get(session, "inline_edit", %{})
  blocks = Content.apply_overrides(Content.default_blocks(), overrides)
  {:ok, assign(socket, blocks: blocks, admin: false, editing: nil)}
end

def handle_event("save_block", %{"id" => id, "html" => html}, socket) do
  {:reply, %{ok: true}, assign(socket, :blocks, Content.put_html(socket.assigns.blocks, id, html))}
end