From 0d57694cee831371ef30758a72d867213d44aef4 Mon Sep 17 00:00:00 2001 From: Annie Seaward Date: Sat, 21 Mar 2026 08:34:43 +0000 Subject: [PATCH 1/5] feat: turn selected text into a link when pasting a URL --- .../controllers/link_paste_controller.js | 42 +++++++++++++ app/views/messages/edit.html.erb | 2 +- app/views/rooms/show/_composer.html.erb | 2 +- test/system/link_paste_test.rb | 59 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/link_paste_controller.js create mode 100644 test/system/link_paste_test.rb diff --git a/app/javascript/controllers/link_paste_controller.js b/app/javascript/controllers/link_paste_controller.js new file mode 100644 index 000000000..a69d8136d --- /dev/null +++ b/app/javascript/controllers/link_paste_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +const URL_PATTERN = /^(?:[a-z0-9]+:\/\/[^\s]+|www\.[^\s.]+\.[^\s]+)$/ + +export default class extends Controller { + connect() { + this._boundHandle = this.handle.bind(this) + this.element.addEventListener("paste", this._boundHandle, true) + } + + disconnect() { + this.element.removeEventListener("paste", this._boundHandle, true) + } + + handle(event) { + const editor = this.element.editor + if (!editor) return + + const [ start, end ] = editor.getSelectedRange() + if (start === end) return + + const url = this.#getClipboardUrl(event.clipboardData) + if (!url || !URL_PATTERN.test(url)) return + + event.preventDefault() + event.stopPropagation() + editor.activateAttribute("href", url) + } + + #getClipboardUrl(clipboardData) { + // When a hyperlink is copied with Cmd+C the URL lives in text/html as an href, + // while text/plain contains the anchor's display text (not the URL itself). + const html = clipboardData?.getData("text/html") + if (html) { + const doc = new DOMParser().parseFromString(html, "text/html") + const anchors = doc.querySelectorAll("a[href]") + if (anchors.length === 1) return anchors[0].href + } + + return clipboardData?.getData("text/plain")?.trim().replaceAll(/[\r\n]+/g, "") + } +} diff --git a/app/views/messages/edit.html.erb b/app/views/messages/edit.html.erb index d9e5be39f..7b443f38e 100644 --- a/app/views/messages/edit.html.erb +++ b/app/views/messages/edit.html.erb @@ -22,7 +22,7 @@ aria: { multiline: "true", label: "Edit message" }, autofocus: true, data: { - controller: "rich-autocomplete", + controller: "rich-autocomplete link-paste", action: rich_text_data_actions, rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %> diff --git a/app/views/rooms/show/_composer.html.erb b/app/views/rooms/show/_composer.html.erb index b6b0dcbae..bc6c4ffd8 100644 --- a/app/views/rooms/show/_composer.html.erb +++ b/app/views/rooms/show/_composer.html.erb @@ -23,7 +23,7 @@ style: "order: -1", aria: { multiline: "true", label: "Write a message" }, data: { - controller: "rich-autocomplete", + controller: "rich-autocomplete link-paste", action: rich_text_data_actions, rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id), permitted_attachment_types: "application/vnd.actiontext.opengraph-embed", diff --git a/test/system/link_paste_test.rb b/test/system/link_paste_test.rb new file mode 100644 index 000000000..ef03d6eb5 --- /dev/null +++ b/test/system/link_paste_test.rb @@ -0,0 +1,59 @@ +require "application_system_test_case" + +class LinkPasteTest < ApplicationSystemTestCase + setup do + sign_in "jz@37signals.com" + join_room rooms(:designers) + end + + test "pasting a URL over selected text creates a link" do + fill_in_rich_text_area "Write a message", with: "Click here for more info" + paste_into_editor("Write a message", text: "https://example.com", selection: [0, 10]) + assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://example.com']") + end + + test "pasting a URL without a selection does not create a link" do + fill_in_rich_text_area "Write a message", with: "Hello" + paste_into_editor("Write a message", text: "https://example.com", selection: [5, 5]) + assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + end + + test "pasting a URL with embedded line breaks creates a link with the line breaks removed" do + fill_in_rich_text_area "Write a message", with: "Click here for more info" + paste_into_editor("Write a message", text: "https://app.frontapp.com/inboxes/teammates/\n10381066/inbox/all/56924074506", selection: [0, 10]) + assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") + end + + test "pasting non-URL text over selected text does not create a link" do + fill_in_rich_text_area "Write a message", with: "Click here for more info" + paste_into_editor("Write a message", text: "just some text", selection: [0, 10]) + assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + end + + test "pasting an incomplete URL over selected text does not create a link" do + fill_in_rich_text_area "Write a message", with: "Click here for more info" + paste_into_editor("Write a message", text: "www.google", selection: [0, 10]) + assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + end + + test "pasting a hyperlink copied directly from a browser creates a link from the HTML href" do + fill_in_rich_text_area "Write a message", with: "Click here for more info" + paste_into_editor("Write a message", text: "Inbox", selection: [0, 10], + html: "Inbox") + assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") + end + + private + + def paste_into_editor(label, text:, selection:, html: nil) + formats = { "text/plain" => text, "text/html" => html.to_s } + page.find(:rich_textarea, label).execute_script(<<~JS, formats.to_json, *selection) + const [ formatsJson, start, end_ ] = arguments + const formats = JSON.parse(formatsJson) + this.editor.setSelectedRange([ start, end_ ]) + const event = new Event("paste", { bubbles: true, cancelable: true }) + Object.defineProperty(event, "clipboardData", { value: { getData: (type) => formats[type] ?? "" } }) + this.dispatchEvent(event) + JS + end +end From 059f45ca74b4a407625ba6b8fff99894f03c971b Mon Sep 17 00:00:00 2001 From: Annie Seaward Date: Mon, 23 Mar 2026 10:19:52 +0000 Subject: [PATCH 2/5] style: fix lint spacing --- test/system/link_paste_test.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/system/link_paste_test.rb b/test/system/link_paste_test.rb index ef03d6eb5..bef555e34 100644 --- a/test/system/link_paste_test.rb +++ b/test/system/link_paste_test.rb @@ -8,37 +8,37 @@ class LinkPasteTest < ApplicationSystemTestCase test "pasting a URL over selected text creates a link" do fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "https://example.com", selection: [0, 10]) + paste_into_editor("Write a message", text: "https://example.com", selection: [ 0, 10 ]) assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://example.com']") end test "pasting a URL without a selection does not create a link" do fill_in_rich_text_area "Write a message", with: "Hello" - paste_into_editor("Write a message", text: "https://example.com", selection: [5, 5]) + paste_into_editor("Write a message", text: "https://example.com", selection: [ 5, 5 ]) assert page.find(:rich_textarea, "Write a message").has_no_css?("a") end test "pasting a URL with embedded line breaks creates a link with the line breaks removed" do fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "https://app.frontapp.com/inboxes/teammates/\n10381066/inbox/all/56924074506", selection: [0, 10]) + paste_into_editor("Write a message", text: "https://app.frontapp.com/inboxes/teammates/\n10381066/inbox/all/56924074506", selection: [ 0, 10 ]) assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") end test "pasting non-URL text over selected text does not create a link" do fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "just some text", selection: [0, 10]) + paste_into_editor("Write a message", text: "just some text", selection: [ 0, 10 ]) assert page.find(:rich_textarea, "Write a message").has_no_css?("a") end test "pasting an incomplete URL over selected text does not create a link" do fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "www.google", selection: [0, 10]) + paste_into_editor("Write a message", text: "www.google", selection: [ 0, 10 ]) assert page.find(:rich_textarea, "Write a message").has_no_css?("a") end test "pasting a hyperlink copied directly from a browser creates a link from the HTML href" do fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "Inbox", selection: [0, 10], + paste_into_editor("Write a message", text: "Inbox", selection: [ 0, 10 ], html: "Inbox") assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") end From 9b07c66df685d7385e0810bb57baa61ff5e5c4fc Mon Sep 17 00:00:00 2001 From: Annie Seaward Date: Mon, 23 Mar 2026 10:42:43 +0000 Subject: [PATCH 3/5] chore: upgrade brakeman --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 880bb62a6..2e87b4b89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,7 +137,7 @@ GEM bcrypt (3.1.20) benchmark (0.5.0) bigdecimal (4.0.1) - brakeman (7.1.2) + brakeman (8.0.4) racc builder (3.3.0) capybara (3.40.0) From 37cefd59ce9cc75c7655ddd08d8973ee0183c357 Mon Sep 17 00:00:00 2001 From: Annie Seaward Date: Mon, 23 Mar 2026 13:31:59 +0000 Subject: [PATCH 4/5] test: use fixed consts for test labels --- test/system/link_paste_test.rb | 39 ++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/test/system/link_paste_test.rb b/test/system/link_paste_test.rb index bef555e34..e9da3edab 100644 --- a/test/system/link_paste_test.rb +++ b/test/system/link_paste_test.rb @@ -1,46 +1,49 @@ require "application_system_test_case" class LinkPasteTest < ApplicationSystemTestCase + LINK_TEXT = "Click here for more info" + EDITOR_LABEL = "Write a message" + setup do sign_in "jz@37signals.com" join_room rooms(:designers) end test "pasting a URL over selected text creates a link" do - fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "https://example.com", selection: [ 0, 10 ]) - assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://example.com']") + fill_in_rich_text_area EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "https://example.com", selection: [ 0, 10 ]) + assert page.find(:rich_textarea, EDITOR_LABEL).has_css?("a[href='https://example.com']") end test "pasting a URL without a selection does not create a link" do - fill_in_rich_text_area "Write a message", with: "Hello" - paste_into_editor("Write a message", text: "https://example.com", selection: [ 5, 5 ]) - assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + fill_in_rich_text_area EDITOR_LABEL, with: "Hello" + paste_into_editor(EDITOR_LABEL, text: "https://example.com", selection: [ 5, 5 ]) + assert page.find(:rich_textarea, EDITOR_LABEL).has_no_css?("a") end test "pasting a URL with embedded line breaks creates a link with the line breaks removed" do - fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "https://app.frontapp.com/inboxes/teammates/\n10381066/inbox/all/56924074506", selection: [ 0, 10 ]) - assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") + fill_in_rich_text_area EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "https://app.frontapp.com/inboxes/teammates/\n10381066/inbox/all/56924074506", selection: [ 0, 10 ]) + assert page.find(:rich_textarea, EDITOR_LABEL).has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") end test "pasting non-URL text over selected text does not create a link" do - fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "just some text", selection: [ 0, 10 ]) - assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + fill_in_rich_text_area EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "just some text", selection: [ 0, 10 ]) + assert page.find(:rich_textarea, EDITOR_LABEL).has_no_css?("a") end test "pasting an incomplete URL over selected text does not create a link" do - fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "www.google", selection: [ 0, 10 ]) - assert page.find(:rich_textarea, "Write a message").has_no_css?("a") + fill_in_rich_text_area EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "www.google", selection: [ 0, 10 ]) + assert page.find(:rich_textarea, EDITOR_LABEL).has_no_css?("a") end test "pasting a hyperlink copied directly from a browser creates a link from the HTML href" do - fill_in_rich_text_area "Write a message", with: "Click here for more info" - paste_into_editor("Write a message", text: "Inbox", selection: [ 0, 10 ], + fill_in_rich_text_area EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "Inbox", selection: [ 0, 10 ], html: "Inbox") - assert page.find(:rich_textarea, "Write a message").has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") + assert page.find(:rich_textarea, EDITOR_LABEL).has_css?("a[href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506']") end private From 9e00472d2ade37baa32a9dcbf50f75ee2d9c58a8 Mon Sep 17 00:00:00 2001 From: Annie Seaward Date: Mon, 23 Mar 2026 13:32:39 +0000 Subject: [PATCH 5/5] refactor: give link paste controller a name --- app/javascript/controllers/link_paste_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/link_paste_controller.js b/app/javascript/controllers/link_paste_controller.js index a69d8136d..1682bfedc 100644 --- a/app/javascript/controllers/link_paste_controller.js +++ b/app/javascript/controllers/link_paste_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" const URL_PATTERN = /^(?:[a-z0-9]+:\/\/[^\s]+|www\.[^\s.]+\.[^\s]+)$/ -export default class extends Controller { +export default class LinkPasteController extends Controller { connect() { this._boundHandle = this.handle.bind(this) this.element.addEventListener("paste", this._boundHandle, true)