diff --git a/Gemfile.lock b/Gemfile.lock index 696e77ae4..ce2e21e67 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 3f1ec3916f468ac2bb626091bcb97469c2c52483 + revision: ea4a511a27db4718fdd7db61d7235c3a374cb1ab specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) diff --git a/app/assets/stylesheets/actiontext-lexical.css b/app/assets/stylesheets/actiontext-lexical.css index a8a3e227a..5e1ab771e 100644 --- a/app/assets/stylesheets/actiontext-lexical.css +++ b/app/assets/stylesheets/actiontext-lexical.css @@ -1,136 +1,195 @@ -lexical-editor { - .node--selected { - &:not(:has(img)) { - box-shadow: 0 0 0 var(--hover-size) var(--hover-color); +@layer components { + lexical-editor { + display: block; + position: relative; + overflow: visible; + + .node--selected:not(.attachment--custom) { + &:not(:has(img)) { + box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + } + + &:has(img) { + img { + box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + } + } } - &:has(img) { - img { - box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + &.lexical-editor--empty { + .lexical-editor__content::before { + content: attr(placeholder); + color: currentColor; + cursor: text; + opacity: 0.66; + pointer-events: none; + position: absolute; + white-space: pre-line; } } } - &.lexical-editor--empty { - .lexical-editor__content::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; + .lexical-dialog-actions { + display: flex; + font-size: var(--text-x-small); + flex: 1 1 0%; + gap: var(--inline-space-half); + margin-block-start: var(--block-space-half); + + .btn { + --radius: 0.3em; + + inline-size: 100%; + justify-content: center; + + &:is([type="submit"]) { + --btn-background: var(--card-color, var(--color-link)); + --btn-color: var(--color-ink-inverted); + --outline-color: var(--card-color, var(--color-link)); + } } - } -} -.lexical-dialog-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--inline-space-half); - margin-block-start: var(--block-space-half); - - .btn { - --radius: 0.3em; - - inline-size: 100%; - justify-content: center; - - &:is([type="submit"]) { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --outline-color: var(--card-color, var(--color-link)); + span { + inline-size: 100%; } } - span { - inline-size: 100%; - } -} + .lexical-editor__content { + --outline-size: max(2px, 0.08em); -.lexical-editor__content { - --outline-size: max(2px, 0.08em); + margin: var(--block-space-half) 0; + min-block-size: calc(7lh + var(--block-space)); + outline: 0; + padding: var(--block-space-half) var(--inline-space); - margin: var(--block-space-half) 0; - min-block-size: calc(7lh + var(--block-space)); - outline: 0; - padding: var(--block-space-half) var(--inline-space); + * { + &:first-child { + margin-block-start: 0; + } - * { - &:first-child { - margin-block-start: 0; - } - - &:last-child { - margin-block-end: 0; - } - } -} - -.lexical-editor--drag-over { - background-color: var(--color-selected); - border-radius: 4px; - outline: 2px dashed var(--color-selected-dark); -} - -lexical-toolbar { - background-color: inherit; - border-block-end: 1px solid var(--color-ink-light); - color: currentColor; - display: flex; - font-size: inherit; - margin: 0; - max-inline-size: 100%; - padding: 0.2em 0; - position: relative; - - dialog { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - padding: var(--block-space) calc(var(--inline-space) * 1.5); - position: absolute; - z-index: 1; - - .input[type="url"] { - min-inline-size: 30ch; + &:last-child { + margin-block-end: 0; + } } } - > button { - aspect-ratio: 4/3.5; - appearance: none; - background-color: transparent; - block-size: 2em; - border: none; - border-radius: 0.2em; + .lexical-editor--drag-over { + background-color: var(--color-selected); + border-radius: 4px; + outline: 2px dashed var(--color-selected-dark); + } + + lexical-toolbar { + background-color: inherit; + border-block-end: 1px solid var(--color-ink-light); color: currentColor; - cursor: pointer; - display: grid; + display: flex; font-size: inherit; - inline-size: auto; - place-items: center; - transition: background-color 300ms ease; + margin: 0; + max-inline-size: 100%; + padding: 0.2em 0; + position: relative; - svg { - -webkit-touch-callout: none; - block-size: 0.85em; - fill: currentColor; - grid-area: 1/1; + dialog { + background-color: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + border-radius: 0.5em; + box-shadow: var(--shadow); + color: var(--color-ink); + padding: var(--block-space) calc(var(--inline-space) * 1.5); + position: absolute; + z-index: 1; + + .input[type="url"] { + min-inline-size: 30ch; + } + } + + > button { + aspect-ratio: 4/3.5; + appearance: none; + background-color: transparent; + block-size: 2em; + border: none; + border-radius: 0.2em; + color: currentColor; + cursor: pointer; + display: grid; + font-size: inherit; inline-size: auto; - user-select: none; - } + place-items: center; + transition: background-color 300ms ease; - &:is(:focus, :hover) { - background-color: var(--color-ink-lighter); - box-shadow: none; - } + svg { + -webkit-touch-callout: none; + block-size: 0.85em; + fill: currentColor; + grid-area: 1/1; + inline-size: auto; + user-select: none; + } - &:is(:active) { + &:is(:focus, :hover) { + background-color: var(--color-ink-lighter); + box-shadow: none; + } + + &:is(:active) { + background-color: var(--color-selected); + } + } + } + + /* Prompt + /* ------------------------------------------------------------------------ */ + + .lexical-prompt-menu { + --lexical-prompt-avatar-size: 24px; + --lexical-prompt-min-width: 20ch; + --lexical-prompt-padding: 0.5ch; + + background-color: var(--color-canvas); + border-radius: calc(var(--lexical-prompt-padding) * 2); + box-shadow: var(--shadow); + font-size: var(--text-small); + list-style: none; + margin: 0; + min-inline-size: var(--lexical-prompt-min-width); + padding: var(--lexical-prompt-padding); + visibility: hidden; + z-index: var(--z-popup); + } + + .lexical-prompt-menu--visible { + visibility: initial; + } + + .lexical-prompt-menu__item { + align-items: center; + border-radius: 0.5ch; + cursor: pointer; + display: flex; + gap: var(--lexical-prompt-padding); + padding: var(--lexical-prompt-padding); + + &:hover { background-color: var(--color-selected); } + + &[aria-selected] { + box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset; + + &:hover { + --hover-size: 0; + } + } + + img { + block-size: var(--lexical-prompt-avatar-size); + border-radius: 50%; + flex-shrink: 0; + inline-size: var(--lexical-prompt-avatar-size); + margin: 0; + } } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 9b8c91cf6..897ab0127 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -20,7 +20,7 @@ :where(h5) { font-size: 0.83em; } :where(h6) { font-size: 0.67em; } - :where(p, ul, ol, dl, blockquote, figure, .attachment) { + :where(p, ul, ol, dl, blockquote, figure:not(.attachment--custom), .attachment:not(.attachment--custom)) { margin-block: var(--block-margin); overflow-wrap: break-word; text-wrap: pretty; @@ -54,7 +54,7 @@ } /* Attachments */ - .attachment { + .attachment:not(.attachment--custom) { block-size: auto; display: inline-block; inline-size: 100%; @@ -221,7 +221,7 @@ } /* Attachment junk gets wrapped in a paragraph causing unwanted space */ - p:has(action-text-attachment) { + p:has(action-text-attachment[src]) { display: none; + * { @@ -329,5 +329,44 @@ } } } + + /* Custom attachments such as mentions, etc. */ + .attachment--custom { + display: inline; + padding: 0; + margin: 0; + white-space: normal; + } + + .attachment--custom, + action-text-attachment { + --attachment-image-size: 1em; + --attachment-selected-color: oklch(var(--lch-blue-dark)); + + align-items: center; + display: inline-flex; + gap: 0.25ch; + position: relative; + vertical-align: bottom; + + lexical-editor & { + cursor: pointer; + } + + img { + block-size: var(--attachment-image-size); + border-radius: 50%; + inline-size: var(--attachment-image-size); + } + + &.node--selected { + background: var(--attachment-selected-color); + box-shadow: + -0.25ch 0 0 var(--attachment-selected-color), + 0.5ch 0 0 var(--attachment-selected-color); + border-radius: 99rem; + color: var(--color-ink-inverted); + } + } } } diff --git a/app/controllers/prompts/cards_controller.rb b/app/controllers/prompts/cards_controller.rb new file mode 100644 index 000000000..e20ae39a2 --- /dev/null +++ b/app/controllers/prompts/cards_controller.rb @@ -0,0 +1,32 @@ +class Prompts::CardsController < ApplicationController + MAX_RESULTS = 10 + + def index + @cards = if filter_param.present? + prepending_exact_matches_by_id(published_cards.mentioning(params[:filter])) + else + @cards = published_cards.latest + end + + if stale? etag: @cards + render layout: false + end + end + + private + def filter_param + params[:filter] + end + + def published_cards + Current.user.accessible_cards.published.limit(MAX_RESULTS) + end + + def prepending_exact_matches_by_id(cards) + if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter]) + [ card_by_id ] + cards + else + cards + end + end +end diff --git a/app/controllers/prompts/collections/users_controller.rb b/app/controllers/prompts/collections/users_controller.rb new file mode 100644 index 000000000..1802f8fd1 --- /dev/null +++ b/app/controllers/prompts/collections/users_controller.rb @@ -0,0 +1,11 @@ +class Prompts::Collections::UsersController < ApplicationController + include CollectionScoped + + def index + @users = @collection.users + + if stale? etag: @users + render layout: false + end + end +end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb new file mode 100644 index 000000000..2e0b63a93 --- /dev/null +++ b/app/helpers/rich_text_helper.rb @@ -0,0 +1,9 @@ +module RichTextHelper + def mentions_prompt(collection) + content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention" + end + + def cards_prompt + content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true + end +end diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index 79ca110bd..86d017533 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -19,6 +19,10 @@ module Mentions private def scan_mentionees + mentionees_from_plain_text | mentionees_from_rich_text + end + + def mentionees_from_plain_text scan_mentioned_handles.filter_map do |mention| mentionable_users.find { |user| user.mentionable_handles.include?(mention) } end @@ -28,6 +32,14 @@ module Mentions mentionable_content.scan(/(? <%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form local-save", - local_save_key_value: "comment-#{card.id}", - action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> + local_save_key_value: "comment-#{card.id}", + action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card), - data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } %> + data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } do %> + <%= mentions_prompt(@card.collection) %> + <%= cards_prompt %> + <% end %> <%= form.button class: "comment__submit btn btn--reversed flex-item-justify-start" do %> Post this comment <% end %> diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index 726693c47..d236e1b79 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -8,7 +8,10 @@
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> - <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %> + <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> + <%= mentions_prompt(@card.collection) %> + <%= cards_prompt %> + <% end %>
<%= form.button class: "btn btn--reversed", type: :submit do %> Save changes @@ -18,7 +21,7 @@ <% end %> <%= tag.button type: :submit, class: "btn btn--negative flex-item-justify-end", form: dom_id(@comment, :delete_form), - data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %> + data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %> <%= icon_tag "trash" %> Delete <% end %> diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 0a45685e9..fe66f6020 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -20,7 +20,10 @@
<%= form.rich_textarea :description, class: "card-field__description", placeholder: "Add some notes, context, pictures, or video about this…", - data: { action: "actiontext:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %> + data: { action: "actiontext:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %> + <%= mentions_prompt(card.collection) %> + <%= cards_prompt %> + <% end %>
<% end %> <% end %> diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb index d7cbe9e6c..e438dbbdb 100644 --- a/app/views/cards/edit.html.erb +++ b/app/views/cards/edit.html.erb @@ -10,7 +10,10 @@ <%= form.rich_textarea :description, class: "card-field__description rich-text-content", placeholder: "Add some notes, context, pictures, or video about this…", - data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %> + data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %> + <%= mentions_prompt(@card.collection) %> + <%= cards_prompt %> + <% end %>
<%= form.button "Save changes", type: :submit, class: "btn btn--reversed", style: "--btn-background: #{@card.color}" %> <%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %> diff --git a/app/views/prompts/cards/_card.html.erb b/app/views/prompts/cards/_card.html.erb new file mode 100644 index 000000000..ebecb20a6 --- /dev/null +++ b/app/views/prompts/cards/_card.html.erb @@ -0,0 +1,8 @@ + + + + diff --git a/app/views/prompts/cards/index.html.erb b/app/views/prompts/cards/index.html.erb new file mode 100644 index 000000000..253e6e543 --- /dev/null +++ b/app/views/prompts/cards/index.html.erb @@ -0,0 +1 @@ +<%= render partial: "prompts/cards/card", collection: @cards %> diff --git a/app/views/prompts/collections/users/_user.html.erb b/app/views/prompts/collections/users/_user.html.erb new file mode 100644 index 000000000..836467bca --- /dev/null +++ b/app/views/prompts/collections/users/_user.html.erb @@ -0,0 +1,10 @@ +" sgid="<%= user.attachable_sgid %>"> + + + diff --git a/app/views/prompts/collections/users/index.html.erb b/app/views/prompts/collections/users/index.html.erb new file mode 100644 index 000000000..3ece68eab --- /dev/null +++ b/app/views/prompts/collections/users/index.html.erb @@ -0,0 +1 @@ +<%= render partial: "prompts/collections/users/user", collection: @users %> diff --git a/app/views/users/_attachable.html.erb b/app/views/users/_attachable.html.erb new file mode 100644 index 000000000..d6e474b6c --- /dev/null +++ b/app/views/users/_attachable.html.erb @@ -0,0 +1,2 @@ +<%= avatar_image_tag user %> +<%= user.first_name %> diff --git a/config/routes.rb b/config/routes.rb index 3e9761dd4..db0cf3cd6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,15 @@ Rails.application.routes.draw do resources :pins end + namespace :prompts do + resources :cards + resources :collections do + scope module: :collections do + resources :users + end + end + end + namespace :public do resources :collections do scope module: :collections do diff --git a/db/schema.rb b/db/schema.rb index d27ab0295..dff0ccb02 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -324,6 +324,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false + t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "users", force: :cascade do |t| diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 479604bf0..89fca8331 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -2200,7 +2200,23 @@ indexes: nulls_not_distinct: comment: valid: true - tags: [] + tags: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: tags + name: index_tags_on_account_id_and_title + unique: true + columns: + - title + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true users: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: users diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb index f7454a322..006d77ec0 100644 --- a/test/models/concerns/mentions_test.rb +++ b/test/models/concerns/mentions_test.rb @@ -5,7 +5,7 @@ class MentionsTest < ActiveSupport::TestCase Current.session = sessions(:david) end - test "create mentions when creating messages" do + test "create mentions from plain text mentions" do assert_difference -> { Mention.count }, +1 do perform_enqueued_jobs only: Mention::CreateJob do collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" @@ -13,6 +13,27 @@ class MentionsTest < ActiveSupport::TestCase end end + test "create mentions from rich text mentions" do + assert_difference -> { Mention.count }, +1 do + perform_enqueued_jobs only: Mention::CreateJob do + attachment = ActionText::Attachment.from_attachable(users(:david)) + collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, #{attachment.to_html}?" + end + end + end + + test "can't mention users that don't have access to the collection" do + collections(:writebook).update! all_access: false + collections(:writebook).accesses.revoke_from(users(:david)) + + assert_no_difference -> { Mention.count }, +1 do + perform_enqueued_jobs only: Mention::CreateJob do + attachment = ActionText::Attachment.from_attachable(users(:david)) + collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, #{attachment.to_html}?" + end + end + end + test "mentionees are added as watchers of the card" do perform_enqueued_jobs only: Mention::CreateJob do card = collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup @kevin?"