Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { TrixContentExtension } from "../extensions/trix_content_extension"
import { TablesExtension } from "../extensions/tables_extension"
import { AttachmentsExtension } from "../extensions/attachments_extension.js"
import { FormatEscapeExtension } from "../extensions/format_escape_extension.js"
import { LinkOpenerExtension } from "../extensions/link_opener_extension.js"


export class LexicalEditorElement extends HTMLElement {
Expand Down Expand Up @@ -142,7 +143,8 @@ export class LexicalEditorElement extends HTMLElement {
TrixContentExtension,
TablesExtension,
AttachmentsExtension,
FormatEscapeExtension
FormatEscapeExtension,
LinkOpenerExtension
]
}

Expand Down
63 changes: 63 additions & 0 deletions src/extensions/link_opener_extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { defineExtension } from "lexical"
import { IS_APPLE, mergeRegister } from "@lexical/utils"
import { registerEventListener } from "../helpers/listener_helper.js"
import LexxyExtension from "./lexxy_extension.js"

export class LinkOpenerExtension extends LexxyExtension {
get enabled() {
return this.editorElement.supportsRichText
}

get lexicalExtension() {
return defineExtension({
name: "lexxy/link-opener",
register: () => {
return mergeRegister(
registerEventListener(window, "keydown", this.#update.bind(this)),
registerEventListener(window, "keyup", this.#update.bind(this)),
registerEventListener(window, "blur", this.#disable.bind(this)),
registerEventListener(window, "focus", this.#refresh.bind(this))
)
}
})
}

#update(event) {
if (this.#isModified(event)) {
this.#enable()
} else {
this.#disable()
Comment thread
zachasme marked this conversation as resolved.
}
}

#refresh() {
// Chrome dispatches events without modifier keys *for a while* after changing tabs
setTimeout(() => {
window.addEventListener("mousemove", this.#update.bind(this), { once: true })
}, 200)
Comment thread
zachasme marked this conversation as resolved.
}
Comment thread
zachasme marked this conversation as resolved.

#isModified(event) {
return IS_APPLE ? event.metaKey : event.ctrlKey
}

#enable() {
for (const anchor of this.#anchors) {
anchor.setAttribute("contenteditable", "false")
anchor.setAttribute("target", "_blank")
anchor.setAttribute("rel", "noopener noreferrer")
}
}

#disable() {
for (const anchor of this.#anchors) {
anchor.removeAttribute("contenteditable")
anchor.removeAttribute("target")
anchor.removeAttribute("rel")
}
}

get #anchors() {
return this.editorElement.editorContentElement?.querySelectorAll("a") ?? []
}
}
60 changes: 60 additions & 0 deletions test/browser/tests/editor/link_opener.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from "../../test_helper.js"
import { expect } from "@playwright/test"

const modifier = process.platform === "darwin" ? "Meta" : "Control"

test.describe("Link opener", () => {
test.beforeEach(async ({ page, editor }) => {
await page.goto("/")
await editor.waitForConnected()
await editor.setValue('<p>Visit <a href="https://example.com">example</a> today</p>')
await editor.flush()
})

test("holding modifier makes links non-editable", async ({ page, editor }) => {
const anchor = editor.content.locator("a")

await expect(anchor).not.toHaveAttribute("contenteditable")
await page.keyboard.down(modifier)
await expect(anchor).toHaveAttribute("contenteditable", "false")
await page.keyboard.up(modifier)
await expect(anchor).not.toHaveAttribute("contenteditable")
})

test("holding modifier sets target and rel on links", async ({ page, editor }) => {
const anchor = editor.content.locator("a")

await page.keyboard.down(modifier)
await expect(anchor).toHaveAttribute("target", "_blank")
await expect(anchor).toHaveAttribute("rel", "noopener noreferrer")
await page.keyboard.up(modifier)
await expect(anchor).not.toHaveAttribute("target")
await expect(anchor).not.toHaveAttribute("rel")
})

test("applies to all links in the editor", async ({ editor, page }) => {
await editor.setValue(
'<p><a href="https://a.com">first</a> and <a href="https://b.com">second</a></p>',
)
await editor.flush()

const anchors = editor.content.locator("a")

await page.keyboard.down(modifier)
await expect(anchors.nth(0)).toHaveAttribute("contenteditable", "false")
await expect(anchors.nth(1)).toHaveAttribute("contenteditable", "false")
await page.keyboard.up(modifier)
await expect(anchors.nth(0)).not.toHaveAttribute("contenteditable")
await expect(anchors.nth(1)).not.toHaveAttribute("contenteditable")
})

test("clears link attributes on window blur", async ({ page, editor }) => {
const anchor = editor.content.locator("a")

await page.keyboard.down(modifier)
await expect(anchor).toHaveAttribute("contenteditable", "false")

await page.evaluate(() => window.dispatchEvent(new Event("blur")))
await expect(anchor).not.toHaveAttribute("contenteditable")
})
})
Loading