chore: added editor.js example

This commit is contained in:
Denis Donici 2026-03-06 11:26:59 +02:00 committed by Wout De Puysseleir
parent 7a5baead81
commit d7c0cf8fae
9 changed files with 387 additions and 3 deletions

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type {Attachment} from "svelte/attachments"
let {initialContent = {blocks: []}, live} = $props()
// Plain const snapshot — not reactive, so the attachment only runs once on mount
// and the editor isn't re-created when the server echoes back saved content.
const initialData = JSON.parse(JSON.stringify(initialContent))
let isSyncing = $state(false)
let syncedBlocks = $state<number | null>(null)
let editorSync: (() => Promise<void>) | null = null
const editorAttachment: Attachment<HTMLElement> = element => {
let editor: any = null
;(async () => {
const {default: EditorJS} = await import("@editorjs/editorjs")
const {default: Header} = await import("@editorjs/header")
const {default: List} = await import("@editorjs/list")
editor = new EditorJS({
holder: element,
tools: {
header: {class: Header, config: {levels: [2, 3], defaultLevel: 2}},
list: {class: List, inlineToolbar: true},
},
data: initialData,
placeholder: "Start writing...",
})
editorSync = async () => {
isSyncing = true
try {
await editor.isReady
const data = await editor.save()
live.pushEvent("sync_content", data)
syncedBlocks = data.blocks.length
} finally {
isSyncing = false
}
}
})()
return () => {
editorSync = null
editor?.destroy()
}
}
</script>
<div>
<div
{@attach editorAttachment}
data-testid="editor-container"
class="min-h-[200px] border border-base-300 rounded-lg bg-base-50 px-4 py-3 text-sm [&_.codex-editor\_\_redactor]:pb-0"
></div>
<div class="flex justify-between items-center mt-4">
{#if syncedBlocks !== null}
<span data-testid="editor-saved-blocks" class="text-sm text-success">
{syncedBlocks} block{syncedBlocks === 1 ? "" : "s"} saved locally
</span>
{:else}
<span></span>
{/if}
<button data-testid="editor-save-btn" onclick={() => editorSync?.()} disabled={isSyncing} class="btn bg-brand text-white btn-sm">
{isSyncing ? "Syncing..." : "Sync with server"}
</button>
</div>
</div>

View file

@ -48,6 +48,7 @@ defmodule ExampleWeb.Layouts do
label: "Advanced",
links: [
%{label: "Client Loading", to: ~p"/live-client-side-loading"},
%{label: "Rich Editor (@attach)", to: ~p"/live-editor"},
%{label: "SSR Demo", to: ~p"/live-ssr"}
]
},

View file

@ -0,0 +1,83 @@
defmodule ExampleWeb.LiveEditor do
use ExampleWeb, :live_view
@initial_content %{
"blocks" => [
%{
"type" => "header",
"data" => %{"text" => "Welcome to the Rich Editor", "level" => 2}
},
%{
"type" => "paragraph",
"data" => %{
"text" =>
"This editor is initialized via Svelte 5's {@attach} directive. Edit this content and click \"Save to server\" to sync it back."
}
}
]
}
def mount(_params, _session, socket) do
{:ok,
assign(socket,
content: @initial_content,
block_count: length(@initial_content["blocks"]),
last_save: nil
)}
end
def handle_event("sync_content", %{"blocks" => blocks} = content, socket) do
IO.inspect(blocks)
{:noreply,
assign(socket,
content: content,
block_count: length(blocks),
last_save: DateTime.utc_now()
)}
end
def render(assigns) do
~H"""
<div class="min-h-screen bg-base-200/40 py-8 px-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-center text-2xl font-light my-4">Rich Editor (@attach)</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Editor.js initialized via Svelte 5's <code class="font-mono">&#123;@attach&#125;</code>
directive. Dynamic imports keep browser-only APIs out of the SSR bundle. Syncing will push editor content back to Phoenix LiveView.
</p>
<div class="flex flex-col gap-8">
<section class="card bg-base-100 shadow-lg border border-base-300/50">
<div class="card-body gap-4">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
LiveSvelte
</span>
<.svelte name="RichEditor" props={%{initialContent: @content}} socket={@socket} />
</div>
</section>
<section class="card bg-base-100 shadow-lg border border-base-300/50">
<div class="card-body gap-4">
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
Server state
</span>
<p class="text-sm text-base-content/70">
Blocks saved: <strong data-testid="block-count">{@block_count}</strong>
</p>
<%= if @last_save do %>
<p class="text-sm text-base-content/50">
Last saved at {Calendar.strftime(@last_save, "%H:%M:%S")} UTC
</p>
<% else %>
<p data-testid="no-save-yet" class="text-sm text-base-content/40 italic">
No saves yet edit the content above and click "Save to server".
</p>
<% end %>
</div>
</section>
</div>
</div>
</div>
"""
end
end

View file

@ -47,6 +47,7 @@ defmodule ExampleWeb.Router do
live("/live-event-reply", LiveEventReply)
live("/live-navigation", LiveNavigation)
live("/live-navigation/:page", LiveNavigation)
live("/live-editor", LiveEditor)
live("/live-ssr", LiveSsr)
live("/live-composition", LiveComposition)
end

View file

@ -5,6 +5,9 @@
"packages": {
"": {
"dependencies": {
"@editorjs/editorjs": "^2.31.0",
"@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.2",
"dynamic-marquee": "^2.6.5",
"live_svelte": "file:../",
"phoenix": "file:./deps/phoenix",
@ -2887,6 +2890,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@codexteam/icons": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
"integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==",
"license": "MIT"
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -3021,6 +3030,66 @@
"node": ">=18"
}
},
"node_modules/@editorjs/caret": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@editorjs/caret/-/caret-1.0.3.tgz",
"integrity": "sha512-VmgwQJZgL/LQjk049JunzRV1YCa0vDi+BNEpbDmr5cp3lGZllq9QQFO1eI71ZPzvFVn3vvhb+eOif4sAEyGgbw==",
"license": "MIT",
"dependencies": {
"@editorjs/dom": "^1.0.1"
}
},
"node_modules/@editorjs/dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-1.0.1.tgz",
"integrity": "sha512-yLO+86MYOIUr1Jl7SQw23SYT84ggv6aJW0EIRsI3NTHYgnQzmK7Bt2n5ZFupQlB0GJqmKqA5tCue3NKQb+o7Pw==",
"license": "MIT",
"dependencies": {
"@editorjs/helpers": "^1.0.1"
}
},
"node_modules/@editorjs/editorjs": {
"version": "2.31.4",
"resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.31.4.tgz",
"integrity": "sha512-DqqVjEAB6jf1REXfc+cCJVwfyszQFtgAbV4o3+DJ9ivuEnANMDunmP2cJhkh2QiVd9pBIoZT7OFcpC7JhjIkrQ==",
"license": "Apache-2.0",
"dependencies": {
"@editorjs/caret": "^1.0.1",
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.5"
}
},
"node_modules/@editorjs/header": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.8.tgz",
"integrity": "sha512-bsMSs34u2hoi0UBuRoc5EGWXIFzJiwYgkFUYQGVm63y5FU+s8zPBmVx5Ip2sw1xgs0fqfDROqmteMvvmbCy62w==",
"license": "MIT",
"dependencies": {
"@codexteam/icons": "^0.0.5",
"@editorjs/editorjs": "^2.29.1"
}
},
"node_modules/@editorjs/helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-1.0.1.tgz",
"integrity": "sha512-Lmr8ImoQvoROXtzhsIJsA1ZtXzH46DmE6O8hMjn9/AvQq62UfjREjn+Ewi6KxjIZMay2PsgDEbLlsVyNJGEaxw==",
"license": "MIT"
},
"node_modules/@editorjs/list": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@editorjs/list/-/list-2.0.9.tgz",
"integrity": "sha512-rUTgDSt5wygD3Dp24bNyp6vvye/Xf4UWju0ZuvWeP13Z4cu2z1Jb5JFSTEhCou72XUGuf4xVhtsd8cm/bwUS1g==",
"license": "MIT",
"dependencies": {
"@codexteam/icons": "^0.3.2"
}
},
"node_modules/@editorjs/list/node_modules/@codexteam/icons": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.3.3.tgz",
"integrity": "sha512-cp7mkZPgmBuSxigTm3Vb+DtVHYeX7qXfQd7o05vcLD8Ag5WvRlol2QSn5P10k0CDAJwmkH9nQGQLBycErS9lsQ==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@ -7401,6 +7470,18 @@
"node": ">= 0.12.0"
}
},
"node_modules/codex-notifier": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz",
"integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==",
"license": "MIT"
},
"node_modules/codex-tooltip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.5.tgz",
"integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==",
"license": "MIT"
},
"node_modules/collect-v8-coverage": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",

View file

@ -1,6 +1,9 @@
{
"type": "module",
"dependencies": {
"@editorjs/editorjs": "^2.31.0",
"@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.2",
"dynamic-marquee": "^2.6.5",
"live_svelte": "file:../",
"phoenix": "file:./deps/phoenix",

View file

@ -1,13 +1,36 @@
{
"../node_modules/@editorjs/editorjs/dist/editorjs.mjs": {
"file": "assets/editorjs-wYYatyLe.js",
"name": "editorjs",
"src": "../node_modules/@editorjs/editorjs/dist/editorjs.mjs",
"isDynamicEntry": true
},
"../node_modules/@editorjs/header/dist/header.mjs": {
"file": "assets/header-B174FzUh.js",
"name": "header",
"src": "../node_modules/@editorjs/header/dist/header.mjs",
"isDynamicEntry": true
},
"../node_modules/@editorjs/list/dist/editorjs-list.mjs": {
"file": "assets/editorjs-list-CzFPaX3H.js",
"name": "editorjs-list",
"src": "../node_modules/@editorjs/list/dist/editorjs-list.mjs",
"isDynamicEntry": true
},
"css/app.css": {
"file": "assets/app-HULN4QXK.css",
"file": "assets/app-fZIL_d3Z.css",
"src": "css/app.css",
"isEntry": true
},
"js/app.js": {
"file": "assets/app-B6UNF5gU.js",
"file": "assets/app-EcY7_2bB.js",
"name": "app",
"src": "js/app.js",
"isEntry": true
"isEntry": true,
"dynamicImports": [
"../node_modules/@editorjs/editorjs/dist/editorjs.mjs",
"../node_modules/@editorjs/header/dist/header.mjs",
"../node_modules/@editorjs/list/dist/editorjs-list.mjs"
]
}
}

View file

@ -0,0 +1,47 @@
defmodule ExampleWeb.LiveEditorTest do
@moduledoc """
E2E tests for the /live-editor LiveView with the RichEditor Svelte component.
Validates the full pipeline: LiveView LiveSvelte hook Svelte mounts Editor.js via @attach.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
test "page loads and shows heading", %{session: session} do
session
|> visit("/live-editor")
|> assert_has(Query.css("h1", text: "Rich Editor (@attach)"))
end
test "editor container renders after Svelte mounts", %{session: session} do
session = visit(session, "/live-editor")
wait_for_editor(session)
session |> assert_has(Query.css("[data-testid='editor-container']"))
end
test "save button is rendered by Svelte", %{session: session} do
session = visit(session, "/live-editor")
wait_for_editor(session)
session |> assert_has(Query.css("[data-testid='editor-save-btn']"))
end
defp wait_for_editor(session, attempts \\ 50) do
els = session |> all(Query.css("[data-testid='editor-container']"))
cond do
length(els) >= 1 ->
:ok
attempts == 0 ->
raise "timeout waiting for editor-container to render"
true ->
:timer.sleep(100)
wait_for_editor(session, attempts - 1)
end
end
end

View file

@ -0,0 +1,75 @@
defmodule ExampleWeb.PhoenixTest.LiveEditorTest do
@moduledoc """
PhoenixTest (in-process) for LiveEditor (/live-editor).
Validates server-side rendering, data-props contract, and save_content event handling.
Editor.js is browser-only; these tests skip SSR and validate the LiveView layer only.
"""
use ExampleWeb.ConnCase, async: false
import PhoenixTest
import Phoenix.LiveViewTest
@moduletag :phoenix_test
test "renders page heading", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("h1", text: "Rich Editor (@attach)")
end
test "renders description mentioning @attach", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("p", text: "@attach")
end
test "renders RichEditor Svelte component with initialContent prop", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("[data-name='RichEditor']", count: 1)
|> assert_has("[data-props*='initialContent']")
|> assert_has("[data-props*='Welcome to the Rich Editor']")
end
test "initial block count is 2", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("[data-testid='block-count']", text: "2")
end
test "no-save-yet message is shown initially", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("[data-testid='no-save-yet']")
end
test "save_content event updates block count and removes no-save message", %{conn: conn} do
conn
|> visit("/live-editor")
|> assert_has("[data-testid='no-save-yet']")
|> assert_has("[data-testid='block-count']", text: "2")
|> unwrap(fn view ->
render_click(view, "save_content", %{
"blocks" => [
%{"type" => "header", "data" => %{"text" => "Hello", "level" => 2}},
%{"type" => "paragraph", "data" => %{"text" => "World"}},
%{"type" => "paragraph", "data" => %{"text" => "Third block"}}
]
})
end)
|> assert_has("[data-testid='block-count']", text: "3")
|> refute_has("[data-testid='no-save-yet']")
end
test "save_content event with single block updates count to 1", %{conn: conn} do
conn
|> visit("/live-editor")
|> unwrap(fn view ->
render_click(view, "save_content", %{
"blocks" => [
%{"type" => "header", "data" => %{"text" => "Only block", "level" => 2}}
]
})
end)
|> assert_has("[data-testid='block-count']", text: "1")
end
end