diff --git a/src/elements/editor.js b/src/elements/editor.js index e5414e8d0..65aa83f3c 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -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 { @@ -142,7 +143,8 @@ export class LexicalEditorElement extends HTMLElement { TrixContentExtension, TablesExtension, AttachmentsExtension, - FormatEscapeExtension + FormatEscapeExtension, + LinkOpenerExtension ] } diff --git a/src/extensions/link_opener_extension.js b/src/extensions/link_opener_extension.js new file mode 100644 index 000000000..1070f1a4f --- /dev/null +++ b/src/extensions/link_opener_extension.js @@ -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() + } + } + + #refresh() { + // Chrome dispatches events without modifier keys *for a while* after changing tabs + setTimeout(() => { + window.addEventListener("mousemove", this.#update.bind(this), { once: true }) + }, 200) + } + + #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") ?? [] + } +} diff --git a/test/browser/tests/editor/link_opener.test.js b/test/browser/tests/editor/link_opener.test.js new file mode 100644 index 000000000..f5c408e6e --- /dev/null +++ b/test/browser/tests/editor/link_opener.test.js @@ -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('

Visit example today

') + 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( + '

first and second

', + ) + 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") + }) +})