mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: added editor.js example
This commit is contained in:
parent
7a5baead81
commit
d7c0cf8fae
9 changed files with 387 additions and 3 deletions
70
example_project/assets/svelte/RichEditor.svelte
Normal file
70
example_project/assets/svelte/RichEditor.svelte
Normal 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>
|
||||
|
|
@ -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"}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
83
example_project/lib/example_web/live/live_editor.ex
Normal file
83
example_project/lib/example_web/live/live_editor.ex
Normal 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">{@attach}</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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
example_project/package-lock.json
generated
81
example_project/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
47
example_project/test/example_web/live/live_editor_test.exs
Normal file
47
example_project/test/example_web/live/live_editor_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue