Skip to content
Open
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
26 changes: 24 additions & 2 deletions src/extensions/attachments_extension.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { $getSelection, $isDecoratorNode, $isParagraphNode, $splitNode, COMMAND_PRIORITY_NORMAL, DELETE_CHARACTER_COMMAND, defineExtension } from "lexical"
import { $getSelection, $isDecoratorNode, $isNodeSelection, $isParagraphNode, $splitNode, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, DELETE_CHARACTER_COMMAND, KEY_TAB_COMMAND, defineExtension } from "lexical"
import { mergeRegister } from "@lexical/utils"

import { $findOrCreateGalleryForImage, $isImageGalleryNode, ImageGalleryNode } from "../nodes/image_gallery_node"
import { ActionTextAttachmentNode } from "../nodes/action_text_attachment_node"
import { $isActionTextAttachmentNode, ActionTextAttachmentNode } from "../nodes/action_text_attachment_node"
import { ActionTextAttachmentUploadNode } from "../nodes/action_text_attachment_upload_node.js"
import { AttachmentDragAndDrop } from "../editor/attachments/drag_and_drop"

Expand Down Expand Up @@ -39,6 +39,7 @@ export class AttachmentsExtension extends LexxyExtension {
return mergeRegister(
editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
editor.registerCommand(KEY_TAB_COMMAND, $focusCaptionFromSelectedAttachment, COMMAND_PRIORITY_HIGH),
editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
() => dragAndDrop.destroy()
)
Expand Down Expand Up @@ -138,6 +139,27 @@ function $collapseAtGalleryEdge(anchor, backwards) {
}
}

// Tab from a selected attachment moves focus into its caption textarea. The
// textarea carries tabIndex -1 so it is no longer reachable by walking the
// document with Tab; this is the deliberate way back in. Shift+Tab is left
// alone so it still steps backwards out of the editor.
function $focusCaptionFromSelectedAttachment(event) {
if (event.shiftKey) return false

const selection = $getSelection()
if (!$isNodeSelection(selection)) return false

const nodes = selection.getNodes()
const node = nodes.length === 1 ? nodes[0] : null

if ($isActionTextAttachmentNode(node) && node.focusCaption()) {
event.preventDefault()
return true
} else {
return false
}
}

// Manual selection handling to prevent Lexical merging the gallery with a <p> and unwrapping it
function $moveSelectionBeforeGallery(anchor) {
const previousNode = anchor.getNode().getPreviousSibling()
Expand Down
28 changes: 27 additions & 1 deletion src/nodes/action_text_attachment_node.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Lexxy from "../config/lexxy"
import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG } from "lexical"
import { $getEditor, $getNearestRootOrShadowRoot, $setSelection, DecoratorNode, HISTORY_MERGE_TAG } from "lexical"
import { createAttachmentFigure, createElement, isPreviewableImage } from "../helpers/html_helper"
import { $createNodeSelectionWith } from "../helpers/lexical_helper"
import { bytesToHumanSize, extractFileName } from "../helpers/storage_helper"
import { parseBoolean } from "../helpers/string_helper"
import { REWRITE_HISTORY_COMMAND } from "../extensions/rewritable_history_extension"
Expand Down Expand Up @@ -217,6 +218,15 @@ export class ActionTextAttachmentNode extends DecoratorNode {
return this.contentType.startsWith("video/")
}

// The caption textarea is deliberately out of the tab order, so this is the
// only way in from the keyboard. Returns false when there is no caption to
// focus, which lets the Tab handler fall through to default behaviour.
focusCaption() {
const textarea = this.editor.getElementByKey(this.getKey())?.querySelector("figcaption textarea")
textarea?.focus()
return textarea != null
}

#createDOMForPendingPreview() {
const figure = this.createAttachmentFigure(false)
figure.appendChild(this.#createDOMForFile())
Expand Down Expand Up @@ -411,6 +421,8 @@ export class ActionTextAttachmentNode extends DecoratorNode {
const input = createElement("textarea", {
value: this.caption,
placeholder: this.fileName,
ariaLabel: this.isVideo ? "Video caption" : "Image caption",
tabIndex: -1,
rows: "1"
})

Expand Down Expand Up @@ -448,6 +460,20 @@ export class ActionTextAttachmentNode extends DecoratorNode {
}, {
tag: HISTORY_MERGE_TAG
})
} else if (event.key === "Escape") {
// Leave the caption without moving the caret past the attachment: hand
// focus back to the editor and reselect the attachment the caption
// belongs to, so Tab can step back in.
event.preventDefault()
event.target.blur()

this.editor.getRootElement()?.focus({ preventScroll: true })

this.editor.update(() => {
$setSelection($createNodeSelectionWith(this))
}, {
tag: HISTORY_MERGE_TAG
})
}

// Stop all keydown events from bubbling to the Lexical root element.
Expand Down
64 changes: 64 additions & 0 deletions test/browser/tests/attachments/attachment_caption_focus.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test } from "../../test_helper.js"
import { expect } from "@playwright/test"
import { mockActiveStorageUploads } from "../../helpers/active_storage_mock.js"

test.describe("Attachment caption focus", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/attachments.html")
await page.waitForSelector("lexxy-editor[connected]")
await page.waitForSelector("lexxy-toolbar[connected]")
await mockActiveStorageUploads(page)
})

test("Tab past an attachment does not fall into its caption", async ({ page, editor }) => {
await editor.uploadFile("test/fixtures/files/example.png")
await expect(captionOf(page)).toBeVisible({ timeout: 10_000 })

// The reported bug: caret in ordinary text, nothing selected, and Tab lands
// in an image caption instead of moving on.
await editor.focus()
await page.keyboard.press("Home")
await page.keyboard.type("Hello")
await expect(page.locator("figure.attachment.node--selected")).toHaveCount(0)

await page.keyboard.press("Tab")

await expect(captionOf(page)).not.toBeFocused()
})

test("Tab from a selected attachment focuses the caption textarea", async ({ page, editor }) => {
await editor.uploadFile("test/fixtures/files/example.png")

const figure = page.locator("figure.attachment")
await expect(figure).toBeVisible({ timeout: 10_000 })
await selectAttachment(figure)

await page.keyboard.press("Tab")

await expect(figure.locator("figcaption textarea")).toBeFocused()
})

test("Escape from caption restores attachment selection and editor focus", async ({ page, editor }) => {
await editor.uploadFile("test/fixtures/files/example.png")

const caption = captionOf(page)
await expect(caption).toBeVisible({ timeout: 10_000 })

await caption.click()
await caption.pressSequentially("Hello")
await caption.press("Escape")

await expect(page.locator("figure.attachment.node--selected")).toHaveCount(1)
await expect(editor.content).toBeFocused()
})
})

function captionOf(page) {
return page.locator("figure.attachment figcaption textarea")
}

async function selectAttachment(figure) {
await figure.locator("img[src*='/blobs/']").waitFor()
await figure.locator("img").click()
await expect(figure).toHaveClass(/node--selected/)
}
Loading