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) diff --git a/app/javascript/controllers/link_paste_controller.js b/app/javascript/controllers/link_paste_controller.js new file mode 100644 index 000000000..1682bfedc --- /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 LinkPasteController 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..e9da3edab --- /dev/null +++ b/test/system/link_paste_test.rb @@ -0,0 +1,62 @@ +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 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 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 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 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 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 EDITOR_LABEL, with: LINK_TEXT + paste_into_editor(EDITOR_LABEL, text: "Inbox", selection: [ 0, 10 ], + html: "Inbox") + assert page.find(:rich_textarea, EDITOR_LABEL).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