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
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions app/javascript/controllers/link_paste_controller.js
Original file line number Diff line number Diff line change
@@ -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, "")
}
}
2 changes: 1 addition & 1 deletion app/views/messages/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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) } %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/rooms/show/_composer.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions test/system/link_paste_test.rb
Original file line number Diff line number Diff line change
@@ -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: "<a href='https://app.frontapp.com/inboxes/teammates/10381066/inbox/all/56924074506'>Inbox</a>")
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
Loading