fix: insert link/media lands at the cursor and closes the overlay instead of breaking the editor
This commit is contained in:
@@ -34,6 +34,20 @@ export const webKitTextAreaArrowCommand = (event) => {
|
|||||||
return event.shiftKey ? `${command}Select` : command;
|
return event.shiftKey ? `${command}Select` : command;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Applying a remote value via setValue resets the cursor to the buffer start,
|
||||||
|
// which then makes follow-up actions (like link inserts targeting the current
|
||||||
|
// selection) land at position 1,1. Capture and restore the selection so the
|
||||||
|
// cursor survives server-driven reconciles; Monaco clamps out-of-range
|
||||||
|
// positions to the new content.
|
||||||
|
export const applyRemoteEditorValue = (editor, value) => {
|
||||||
|
const selections = editor.getSelections ? editor.getSelections() : null;
|
||||||
|
editor.setValue(value);
|
||||||
|
|
||||||
|
if (selections && selections.length > 0 && editor.setSelections) {
|
||||||
|
editor.setSelections(selections);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
||||||
if (!editor || !isMonacoInputArea(event.target)) {
|
if (!editor || !isMonacoInputArea(event.target)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -92,9 +106,13 @@ export const MonacoEditor = {
|
|||||||
|
|
||||||
if (this.editor.getValue() !== value) {
|
if (this.editor.getValue() !== value) {
|
||||||
this.isApplyingRemoteUpdate = true;
|
this.isApplyingRemoteUpdate = true;
|
||||||
this.editor.setValue(value);
|
|
||||||
|
try {
|
||||||
|
applyRemoteEditorValue(this.editor, value);
|
||||||
|
} finally {
|
||||||
this.isApplyingRemoteUpdate = false;
|
this.isApplyingRemoteUpdate = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.lastKnownValue = value;
|
this.lastKnownValue = value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
||||||
)
|
)
|
||||||
|
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
||||||
@@ -172,7 +172,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
end
|
end
|
||||||
|
|
||||||
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -197,9 +197,10 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
|
|
||||||
if details do
|
if details do
|
||||||
Notify.parent({:post_editor_insert_content, post_id, details})
|
Notify.parent({:post_editor_insert_content, post_id, details})
|
||||||
end
|
assign(socket, :shell_overlay, nil)
|
||||||
|
else
|
||||||
socket
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
id: socket.assigns.post_id,
|
id: socket.assigns.post_id,
|
||||||
content: content
|
content: content
|
||||||
})
|
})
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
@@ -316,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, do_remove_list_value(socket, :categories, category)}
|
{:noreply, do_remove_list_value(socket, :categories, category)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("insert_content", %{"content" => content}, socket) do
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
|
|
||||||
id: socket.assigns.post_id,
|
|
||||||
content: content
|
|
||||||
})
|
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("close_quick_actions", _params, socket) do
|
def handle_event("close_quick_actions", _params, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -892,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if map_size(attrs) == 0 do
|
if map_size(attrs) == 0 do
|
||||||
assign(socket, :shell_overlay, nil)
|
socket
|
||||||
else
|
else
|
||||||
case Posts.update_post(post_id, attrs) do
|
case Posts.update_post(post_id, attrs) do
|
||||||
{:ok, updated_post} ->
|
{:ok, updated_post} ->
|
||||||
@@ -910,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
)
|
)
|
||||||
|> assign(:save_state, :dirty)
|
|> assign(:save_state, :dirty)
|
||||||
|> assign(:dirty?, true)
|
|> assign(:dirty?, true)
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
Notify.dirty(:post, post_id, true)
|
Notify.dirty(:post, post_id, true)
|
||||||
|
|||||||
4
priv/static/assets/app.js
vendored
4
priv/static/assets/app.js
vendored
File diff suppressed because one or more lines are too long
@@ -3427,6 +3427,115 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert output_html =~ "Automatic AI actions stay gated by airplane mode"
|
assert output_html =~ "Automatic AI actions stay gated by airplane mode"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "selecting an internal insert link result pushes the link and closes the overlay", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
{:ok, target} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Link Target", content: "Target body"})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_select_result", %{"id" => target.id})
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{id: _id, content: content})
|
||||||
|
assert content =~ "Link Target"
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "inserting an external link pushes the link and closes the overlay", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_set_tab", %{"tab" => "external"})
|
||||||
|
|
||||||
|
render_change(view, "overlay_update_form", %{
|
||||||
|
"overlay" => %{"url" => "https://example.com", "text" => "Example"}
|
||||||
|
})
|
||||||
|
|
||||||
|
render_click(view, "overlay_insert_external", %{})
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{content: "[Example](https://example.com)"})
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting an insert media result pushes the syntax and closes the overlay", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
media_source_path = Path.join(temp_dir, "insert-media.jpg")
|
||||||
|
File.write!(media_source_path, "fake image body")
|
||||||
|
|
||||||
|
{:ok, media} =
|
||||||
|
Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: media_source_path,
|
||||||
|
title: "Insert Media"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_media']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_set_search", %{"overlay" => %{"query" => "Insert"}})
|
||||||
|
render_click(view, "overlay_select_result", %{"id" => media.id})
|
||||||
|
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{content: content})
|
||||||
|
assert content =~ "bds-media://#{media.id}"
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
test "ai suggestions overlay fetches async results for posts when online", %{project: project} do
|
test "ai suggestions overlay fetches async results for posts when online", %{project: project} do
|
||||||
Application.put_env(:bds, :test_pid, self())
|
Application.put_env(:bds, :test_pid, self())
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,50 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert status == 0, output
|
assert status == 0, output
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "monaco hook preserves cursor and selection when applying a remote value" do
|
||||||
|
script = """
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
class FakeTextArea {}
|
||||||
|
globalThis.HTMLTextAreaElement = FakeTextArea;
|
||||||
|
|
||||||
|
const { applyRemoteEditorValue } = await import("./assets/js/hooks/monaco_editor.js");
|
||||||
|
|
||||||
|
const selections = [{ startLineNumber: 3, startColumn: 5, endLineNumber: 3, endColumn: 9 }];
|
||||||
|
const calls = [];
|
||||||
|
const editor = {
|
||||||
|
getSelections: () => selections,
|
||||||
|
setValue: (value) => calls.push(["setValue", value]),
|
||||||
|
setSelections: (restored) => calls.push(["setSelections", restored])
|
||||||
|
};
|
||||||
|
|
||||||
|
applyRemoteEditorValue(editor, "line one\\nline two\\nline three");
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
["setValue", "line one\\nline two\\nline three"],
|
||||||
|
["setSelections", selections]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Editors without selections still get the value applied.
|
||||||
|
const bareCalls = [];
|
||||||
|
const bareEditor = {
|
||||||
|
getSelections: () => null,
|
||||||
|
setValue: (value) => bareCalls.push(["setValue", value]),
|
||||||
|
setSelections: () => bareCalls.push(["setSelections"])
|
||||||
|
};
|
||||||
|
|
||||||
|
applyRemoteEditorValue(bareEditor, "fresh");
|
||||||
|
assert.deepEqual(bareCalls, [["setValue", "fresh"]]);
|
||||||
|
"""
|
||||||
|
|
||||||
|
{output, status} =
|
||||||
|
System.cmd("node", ["--input-type=module", "--eval", script],
|
||||||
|
cd: "/Users/gb/Projects/bDS2",
|
||||||
|
stderr_to_stdout: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 0, output
|
||||||
|
end
|
||||||
|
|
||||||
test "monaco ESM build config emits served worker bundles" do
|
test "monaco ESM build config emits served worker bundles" do
|
||||||
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
|
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
|
||||||
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")
|
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")
|
||||||
|
|||||||
Reference in New Issue
Block a user