add undo/redo to page editors with keyboard shortcuts
All checks were successful
deploy / deploy (push) Successful in 1m29s
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:
@@ -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
|
||||
|
||||
@@ -27,6 +27,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
|> assign(:editor_show_picker, false)
|
||||
|> assign(:editor_picker_filter, "")
|
||||
@@ -141,11 +143,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
{:ok, new_blocks, message} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> apply_mutation(new_blocks, message, :content)
|
||||
|> assign(:editor_show_picker, false)
|
||||
|> assign(:editor_live_region_message, message)
|
||||
|> reload_block_data(new_blocks)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
@@ -160,13 +159,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|
||||
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
|
||||
{:ok, new_blocks} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> reload_block_data(new_blocks)
|
||||
|
||||
{:halt, socket}
|
||||
{:halt, apply_mutation(socket, new_blocks, "Settings updated", :content)}
|
||||
|
||||
:noop ->
|
||||
{:halt, socket}
|
||||
@@ -304,11 +297,9 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
{:ok, new_blocks} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> apply_mutation(new_blocks, "Image selected", :content)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> reload_block_data(new_blocks)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
@@ -326,15 +317,53 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
field_key => ""
|
||||
}) do
|
||||
{:ok, new_blocks} ->
|
||||
{:halt, apply_mutation(socket, new_blocks, "Image cleared", :content)}
|
||||
|
||||
:noop ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Undo / redo ──────────────────────────────────────────────────
|
||||
|
||||
defp handle_editor_action("undo", _params, socket) do
|
||||
case socket.assigns.editor_history do
|
||||
[prev | rest] ->
|
||||
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editing_blocks, prev)
|
||||
|> assign(:editor_history, rest)
|
||||
|> assign(:editor_future, future)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> reload_block_data(new_blocks)
|
||||
|> assign(:editor_live_region_message, "Undone")
|
||||
|> reload_block_data(prev)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
:noop ->
|
||||
[] ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_action("redo", _params, socket) do
|
||||
case socket.assigns.editor_future do
|
||||
[next | rest] ->
|
||||
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, next)
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, rest)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_live_region_message, "Redone")
|
||||
|> reload_block_data(next)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
[] ->
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
@@ -353,6 +382,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:page, updated_page)
|
||||
|> assign(:editing_blocks, updated_page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> put_flash(:info, "Page saved")
|
||||
|
||||
{:halt, socket}
|
||||
@@ -372,6 +403,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:page, page)
|
||||
|> assign(:editing_blocks, page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> reload_block_data(page.blocks)
|
||||
|> put_flash(:info, "Page reset to defaults")
|
||||
|
||||
@@ -396,6 +429,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editing, true)
|
||||
|> assign(:editing_blocks, page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
|> assign(:editor_show_picker, false)
|
||||
|> assign(:editor_picker_filter, "")
|
||||
@@ -413,6 +448,8 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
|> assign(:editor_show_picker, false)
|
||||
|> assign(:editor_picker_filter, "")
|
||||
@@ -426,9 +463,14 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
end
|
||||
|
||||
defp apply_mutation(socket, new_blocks, message, type) do
|
||||
history =
|
||||
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_live_region_message, message)
|
||||
|
||||
|
||||
@@ -64,14 +64,37 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
<div
|
||||
id="page-editor-live"
|
||||
class="page-editor-live"
|
||||
phx-hook="DirtyGuard"
|
||||
phx-hook="EditorKeyboard"
|
||||
data-dirty={to_string(@editor_dirty)}
|
||||
data-event-prefix="editor_"
|
||||
data-sidebar-open={to_string(@editor_sidebar_open)}
|
||||
>
|
||||
<aside class="page-editor-sidebar" aria-label="Page editor">
|
||||
<div class="page-editor-sidebar-header">
|
||||
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
|
||||
<div class="page-editor-sidebar-actions">
|
||||
<button
|
||||
phx-click="editor_undo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_history == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_history == []}
|
||||
aria-label="Undo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_redo"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm admin-btn-ghost",
|
||||
@editor_future == [] && "opacity-30"
|
||||
]}
|
||||
disabled={@editor_future == []}
|
||||
aria-label="Redo"
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="editor_save"
|
||||
class={[
|
||||
|
||||
Reference in New Issue
Block a user