Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/extensions/provisional_paragraph_extension.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { $addUpdateTag, $getRoot, $getSelection, $isRootOrShadowRoot, COMMAND_PRIORITY_HIGH, HISTORY_MERGE_TAG, RootNode, SELECTION_CHANGE_COMMAND, defineExtension } from "lexical"
import { $addUpdateTag, $getEditor, $getRoot, $getSelection, $isRootOrShadowRoot, COMMAND_PRIORITY_HIGH, HISTORY_MERGE_TAG, RootNode, SELECTION_CHANGE_COMMAND, defineExtension } from "lexical"
import { $descendantsMatching, $firstToLastIterator, $insertFirst, mergeRegister } from "@lexical/utils"
import { $isProvisionalParagraphNode, ProvisionalParagraphNode } from "../nodes/provisional_paragraph_node"
import { isEditorFocused } from "../helpers/lexical_helper"
import LexxyExtension from "./lexxy_extension"


Expand Down Expand Up @@ -55,6 +56,13 @@ function $removeUnneededProvisionalParagraphs(rootNode) {
}

function $markAllProvisionalParagraphsDirty() {
// A provisional paragraph's visibility follows the editor's caret, so there is
// nothing to update while the editor is unfocused. Bailing also avoids a
// reconcile that would write our selection back to the DOM and steal focus when
// the selectionchange came from elsewhere on the page — Gecko (Firefox 152)
// fires selectionchange for outside inputs, Blink and older Gecko do not.
if (!isEditorFocused($getEditor())) return false

// Selection-driven visibility updates must not become standalone undo steps.
$addUpdateTag(HISTORY_MERGE_TAG)

Expand Down
3 changes: 3 additions & 0 deletions test/browser/fixtures/selection_change_command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SELECTION_CHANGE_COMMAND } from "lexical"

window.SELECTION_CHANGE_COMMAND = SELECTION_CHANGE_COMMAND
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { test } from "../../test_helper.js"
import { expect } from "@playwright/test"

const IMG = '<action-text-attachment content-type="image/png" url="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" filename="test.png" filesize="1024" width="200" height="120"></action-text-attachment>'

// #1147: a caret between two stacked block images leaves a leading and trailing
// provisional paragraph in place. A selectionchange anywhere on the page marks
// those dirty, and the resulting reconcile writes the editor's selection back to
// the DOM — pulling focus into the editor and away from wherever the user is
// actually typing.
test.describe("Focus stays put with a caret between stacked images (#1147)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/attachments-enabled.html")
await page.waitForSelector("lexxy-editor[connected]")
await placeCaretBetweenStackedImages(page)
})

test("a selection change elsewhere on the page does not pull focus back into the editor", async ({ page }) => {
const input = page.locator("input[name='post[title]']")
await input.click()
await expect(input).toBeFocused()

// Firefox 152 fires selectionchange for an outside input's caret; Blink and
// older Gecko do not, so replaying the native event can't cover this on the
// bundled browser. Dispatch the command Lexical would raise instead — without
// the guard it drives a focus-stealing reconcile on every engine.
await dispatchSelectionChange(page)

await expect(input).toBeFocused()
})

test("typing in an outside input keeps the caret in that input", async ({ page }) => {
const input = page.locator("input[name='post[title]']")
await input.click()
await input.pressSequentially("hello")

await expect(input).toBeFocused()
await expect(input).toHaveValue("hello")
})
})

async function placeCaretBetweenStackedImages(page) {
const editor = page.locator("lexxy-editor")
await editor.evaluate((el, html) => (el.value = html), IMG + IMG)
await editor.locator(".lexxy-editor__content").evaluate((content) => {
const between = [ ...content.children ].find((child, index) =>
child.classList.contains("provisional-paragraph") && content.children[index - 1]?.tagName === "FIGURE")
if (!between) throw new Error("expected a provisional paragraph between the two stacked images")

const range = document.createRange()
range.selectNodeContents(between)
range.collapse(true)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
})
await page.locator("lexxy-editor").locator(".lexxy-editor__content").press("x")
}

async function dispatchSelectionChange(page) {
await page.addScriptTag({ type: "module", url: "/selection_change_command.js" })
await page.waitForFunction(() => !!window.SELECTION_CHANGE_COMMAND)
await page.locator("lexxy-editor").evaluate((el) => {
el.editor.dispatchCommand(window.SELECTION_CHANGE_COMMAND, undefined)
})
}