add undo/redo to page editors with keyboard shortcuts
All checks were successful
deploy / deploy (push) Successful in 1m29s

History stacks (@history/@future) on both admin editor and live sidebar,
capped at 50 entries. All mutations routed through apply_mutation for
consistent history tracking. EditorKeyboard JS hook combines DirtyGuard
with Ctrl+Z/Ctrl+Shift+Z. Settings panel fade-in animation. 10 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-28 12:16:15 +00:00
parent 22d7b0e92b
commit 79b5161e02
8 changed files with 379 additions and 37 deletions

View File

@@ -26,6 +26,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:page_data, page)
|> assign(:blocks, page.blocks)
|> assign(:allowed_blocks, allowed_blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> assign(:show_picker, false)
|> assign(:picker_filter, "")
@@ -85,10 +87,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:ok, new_blocks, message} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:show_picker, false)
|> assign(:live_region_message, message)}
|> apply_mutation(new_blocks, message)
|> assign(:show_picker, false)}
:noop ->
{:noreply, socket}
@@ -101,10 +101,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
{:noreply, apply_mutation(socket, new_blocks, "Settings updated")}
:noop ->
{:noreply, socket}
@@ -191,8 +188,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> apply_mutation(new_blocks, "Image selected")
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
@@ -208,10 +204,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
) do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)}
{:noreply, apply_mutation(socket, new_blocks, "Image cleared")}
:noop ->
{:noreply, socket}
@@ -282,16 +275,54 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:noreply,
socket
|> assign(:blocks, page.blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")}
end
def handle_event("undo", _params, socket) do
case socket.assigns.history do
[prev | rest] ->
future = [socket.assigns.blocks | socket.assigns.future]
{:noreply,
socket
|> assign(:blocks, prev)
|> assign(:history, rest)
|> assign(:future, future)
|> assign(:dirty, prev != socket.assigns.page_data.blocks)
|> assign(:live_region_message, "Undone")}
[] ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case socket.assigns.future do
[next | rest] ->
history = [socket.assigns.blocks | socket.assigns.history]
{:noreply,
socket
|> assign(:blocks, next)
|> assign(:history, history)
|> assign(:future, rest)
|> assign(:dirty, true)
|> assign(:live_region_message, "Redone")}
[] ->
{:noreply, socket}
end
end
# ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
@@ -318,6 +349,22 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
disabled={@history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="redo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @future == [] && "opacity-30"]}
disabled={@future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button
:if={!@is_custom_page}
phx-click="reset_defaults"
@@ -532,8 +579,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
# ── Helpers ──────────────────────────────────────────────────────
defp apply_mutation(socket, new_blocks, message) do
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
socket
|> assign(:blocks, new_blocks)
|> assign(:history, history)
|> assign(:future, [])
|> assign(:dirty, true)
|> assign(:live_region_message, message)
end