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/3re-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
LiveView → island (props).
The block's current HTML, the mode, and the language list
are handed in as props
when the overlay is rendered.
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.
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.
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