Merge branch 'main' into jz-5-28-25

* main: (58 commits)
  Safari doesn't support CSS `field-sizing: content` so we can shim it for now
  Hide bubbles in trays
  update lexical
  update lexical
  Update script to deal with duplicated records
  Revert "Revert "Replace House with Lexical""
  Revert "Replace House with Lexical"
  Update actiontext lexical
  Remove trace
  Update lexical
  Use existing method
  Update lexical
  Fix merge issues
  Format
  Cleanup
  Format
  Make tests pass with action text
  Don't choke when rendering commands where the card was deleted
  Update lexical
  Simple format excerpts in activity
  ...
This commit is contained in:
Jason Zimdars
2025-05-29 14:21:17 -05:00
54 changed files with 986 additions and 586 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -47,7 +47,7 @@ FROM base
# Install packages needed for deployment
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Copy built artifacts: gems, application
+2
View File
@@ -27,6 +27,8 @@ gem "rqrcode"
gem "redcarpet"
gem "rouge"
gem "jbuilder"
gem "actiontext-lexical", bc: "actiontext-lexical"
gem "image_processing", "~> 1.14"
gem "platform_agent"
# Telemetry and logging
+22
View File
@@ -1,3 +1,10 @@
GIT
remote: https://github.com/basecamp/actiontext-lexical
revision: 6e53a32d373bc73b8b49461338d22727499158b1
specs:
actiontext-lexical (0.1.0)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: 523947a0f0f49f66a8a62eb93e9ba1a345b3076b
@@ -188,6 +195,10 @@ GEM
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday (~> 2.0)
ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -199,6 +210,9 @@ GEM
hashdiff (1.1.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.1.0)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -236,6 +250,9 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (5.2.0)
benchmark
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
@@ -354,6 +371,9 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.32.0)
@@ -448,6 +468,7 @@ PLATFORMS
x86_64-mingw32
DEPENDENCIES
actiontext-lexical!
active_record-tenanted!
bcrypt (~> 3.1.7)
bootsnap
@@ -456,6 +477,7 @@ DEPENDENCIES
capybara
debug
geared_pagination (~> 1.2)
image_processing (~> 1.14)
importmap-rails
jbuilder
kamal
+11
View File
@@ -177,6 +177,17 @@
--color-card-7: oklch(var(--lch-purple-medium));
--color-card-8: oklch(var(--lch-pink-medium));
/* Colors: Syntax highlighting */
--color-code-token__att: oklch(var(--lch-blue-dark));
--color-code-token__comment: oklch(var(--lch-ink-medium));
--color-code-token__function: oklch(var(--lch-purple-dark));
--color-code-token__operator: oklch(var(--lch-red-dark));
--color-code-token__property: oklch(var(--lch-purple-dark));
--color-code-token__punctuation: oklch(var(--lch-ink-dark));
--color-code-token__selector: oklch(var(--lch-green-dark));
--color-code-token__variable: oklch(var(--lch-red-dark));
@media (prefers-color-scheme: dark) {
--lch-canvas: 20% 0.0195 232.58;
--lch-ink-inverted: var(--lch-black);
@@ -0,0 +1,113 @@
lexical-editor {
.node--selected {
&: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);
}
}
}
}
.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;
}
span {
inline-size: 100%;
}
}
.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);
* {
&: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;
}
> 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;
place-items: center;
transition: background-color 300ms ease;
svg {
-webkit-touch-callout: none;
block-size: 0.85em;
fill: currentColor;
grid-area: 1/1;
inline-size: auto;
user-select: none;
}
&:is(:focus, :hover) {
background-color: var(--color-ink-lighter);
box-shadow: none;
}
&:is(:active) {
background-color: var(--color-selected);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
@layer components {
@supports not (field-sizing: content) {
.autoresize__wrapper {
display: grid !important;
position: relative;
> *, &::after {
grid-area: 1 / 1 / 2 / 2;
}
&::after {
content: attr(data-autoresize-clone-value) " ";
font: inherit;
line-height: inherit;
visibility: hidden;
white-space: pre-wrap;
}
}
.autoresize__textarea {
inset: 0;
overflow: hidden;
position: absolute;
resize: none;
}
}
}
+13
View File
@@ -150,6 +150,13 @@
}
}
.card-field__description {
lexical-toolbar {
border-block-start: 1px solid var(--color-ink-light);
margin-block-start: var(--block-margin) !important;
}
}
.card__content {
flex: 2 1 auto;
max-inline-size: 100%;
@@ -167,6 +174,12 @@
line-height: 1.2;
text-wrap: balance;
@supports not (field-sizing: content) {
&:has(textarea) {
text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */
}
}
.card-field__title {
&:is(textarea)::placeholder {
color: inherit;
+6 -17
View File
@@ -1,7 +1,7 @@
@layer components {
.comments {
--avatar-size: 2.33em;
--comment-padding-block: var(--block-space);
--comment-padding-block: var(--block-space-half);
--comment-padding-inline: var(--inline-space-double);
--comment-max: 70ch;
--reaction-size: 2rem;
@@ -47,7 +47,7 @@
}
.comment__avatar {
margin: calc(var(--comment-padding-block) * 0.5) calc(var(--comment-padding-inline) * -0.75);
margin: calc(var(--comment-padding-block) * 0.75) calc(var(--comment-padding-inline) * -0.75);
z-index: 0;
.comment--mine_ & {
@@ -64,7 +64,7 @@
text-align: center;
}
p {
* {
&:first-child {
margin-block-start: 0;
}
@@ -83,6 +83,8 @@
border-radius: 0.2em;
padding:
var(--comment-padding-block)
var(--comment-padding-inline)
calc(var(--comment-padding-block) * 1.5)
var(--comment-padding-inline);
word-wrap: break-word;
}
@@ -112,7 +114,7 @@
max-inline-size: var(--comment-max);
text-align: center;
&::before {
/* Make up space for lack of avatar */
@@ -135,17 +137,4 @@
display: none !important;
}
}
.comment__input {
--input-border-size: 0;
--input-padding: 0;
min-block-size: calc(9lh + (2 * var(--comment-padding-block)));
min-inline-size: 100%;
@supports (field-sizing: content) {
field-sizing: content;
min-block-size: calc(9lh + (2 * var(--comment-padding-block)));
}
}
}
-110
View File
@@ -1,110 +0,0 @@
@layer components {
.markdown-content {
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) {
font-weight: 800;
hyphens: auto;
letter-spacing: -0.02ch;
line-height: 1.1;
margin-block: 0;
overflow-wrap: break-word;
text-wrap: balance;
}
:where(h1, h2, h3, h4, h5, h6) { display: block; }
:where(h1, h2, h3, h4, h5, h6) { margin-block: 0.65em; }
:is(h1, .h1) { font-size: 2em; }
:is(h2, .h2) { font-size: 1.5em; }
:is(h3, .h3) { font-size: 1.17em; }
:is(h4, .h4) { font-size: 1em; }
:is(h5, .h5) { font-size: 0.83em; }
:is(h6, .h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, pre, figure, table, hr) {
margin-block: 0.65lh;
overflow-wrap: break-word;
text-wrap: pretty;
}
:where(hr) {
border-color: var(--color-ink-medium);
border-style: var(--border-style, solid) none none;
margin: 2lh auto;
}
:where(strong) {
font-weight: 700;
}
:is(pre, code, .pre, .code) {
background-color: var(--color-ink-lightest);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
font-family: var(--font-mono);
font-size: 0.85em;
}
:is(pre) {
overflow-x: auto;
/* text-wrap: nowrap; */
}
:where(code) {
padding: 0.1em 0.3em;
}
:is(pre, .pre) {
border-radius: 0.5em;
padding: 0.5lh 2ch;
:is(code, .code) {
background-color: transparent;
border: 0;
font-size: 1em;
padding: 0;
}
}
:where(p) {
hyphens: auto;
letter-spacing: -0.005ch;
}
:is(blockquote, .quote) {
font-style: italic;
margin: 0 3ch;
:is(p) {
hyphens: none;
}
}
:where(table, th, td) {
border: 1px solid var(--color-ink-medium);
}
:where(table) {
border-collapse: collapse;
margin: 1lh 0;
}
:where(th, td) {
padding: 0.2lh 1ch;
text-align: start;
}
:where(th) {
border-block-end-width: 3px;
font-weight: 700;
}
:where(img, video, embed, object) {
max-block-size: 32rem;
/* Links should hug media contained within */
a:has(&) {
display: inline-block;
}
}
}
}
@@ -0,0 +1,331 @@
@layer components {
.rich-text-content {
--block-margin: 0.65lh;
:where(h1, h2, h3, h4, h5, h6) {
display: block;
font-weight: 800;
hyphens: auto;
letter-spacing: -0.02ch;
line-height: 1.1;
margin-block: var(--block-margin);
overflow-wrap: break-word;
text-wrap: balance;
}
:where(h1) { font-size: 2em; }
:where(h2) { font-size: 1.5em; }
:where(h3) { font-size: 1.17em; }
:where(h4) { font-size: 1em; }
:where(h5) { font-size: 0.83em; }
:where(h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
margin-block: var(--block-margin);
overflow-wrap: break-word;
text-wrap: pretty;
}
:where(b, strong) {
font-weight: 700;
}
:where(p, blockquote) {
hyphens: auto;
letter-spacing: -0.005ch;
}
:where(blockquote) {
border-inline-start: 0.25em solid var(--color-ink-lighter);
font-style: italic;
margin: var(--block-margin) 0;
padding: 0.5lh 2ch;
}
:where(img, video, embed, object) {
margin-inline: auto;
max-block-size: 32rem;
/* Links should hug media contained within */
a:has(&) {
display: inline-block;
}
}
/* Attachments */
.attachment {
block-size: auto;
display: inline-block;
inline-size: 100%;
position: relative;
margin-inline: 0;
max-inline-size: 100%;
&:is(figure) img {
block-size: auto;
display: block;
margin-inline: auto;
max-inline-size: 100%;
user-select: none;
}
:where(progress) {
inline-size: 100%;
margin: auto;
}
}
.attachment__caption {
color: var(--color-ink-dark);
display: flex;
font-size: var(--text-small);
flex-wrap: wrap;
line-height: 1.3;
column-gap: 0.5ch;
justify-content: center;
margin: var(--block-space-half) var(--inline-space);
text-align: center;
.input {
inline-size: 100%;
max-inline-size: 100%;
text-align: center;
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
}
}
}
.attachment__figure {
--hover-size: 0;
}
.attachment--file {
align-items: center;
display: inline-flex;
.attachment__caption {
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
text-align: start;
strong {
color: var(--color-ink);
}
}
}
.attachment__icon {
aspect-ratio: 4/5;
background-color: var(--color-canvas);
block-size: calc(2.5lh + 0.5ch);
border: 2px solid var(--color-ink-medium);
border-radius: 0.3em;
color: var(--color-ink-dark);
display: inline-grid;
font-size: var(--text-small);
font-weight: 800;
inline-size: auto;
overflow: clip;
padding-block-start: 0.4lh;
place-content: center;
position: relative;
text-transform: uppercase;
&::before {
background-color: var(--color-ink-medium);
block-size: 0.5lh;
content: "";
display: block;
inline-size: 100%;
inset: 0 0 auto;
position: absolute;
}
.attachment--psd &,
.attachment--key &,
.attachment--sketch &,
.attachment--ai &,
.attachment--eps &,
.attachment--indd &,
.attachment--svg &,
.attachment--ppt &,
.attachment--pptx & {
color: oklch(var(--lch-red-dark));
background-color: oklch(var(--lch-red-lightest));
border-color: oklch(var(--lch-red-medium));
&::before {
background-color: oklch(var(--lch-red-medium));
}
}
.attachment--css &,
.attachment--php &,
.attachment--json &,
.attachment--htm &,
.attachment--html &,
.attachment--rb &,
.attachment--erb &,
.attachment--ts &,
.attachment--js & {
color: oklch(var(--lch-purple-dark));
background-color: oklch(var(--lch-purple-lightest));
border-color: oklch(var(--lch-purple-medium));
&::before {
background-color: oklch(var(--lch-purple-medium));
}
}
.attachment--txt &,
.attachment--pages &,
.attachment--rtf &,
.attachment--md &,
.attachment--doc &,
.attachment--docx & {
color: oklch(var(--lch-blue-dark));
background-color: oklch(var(--lch-blue-lightest));
border-color: oklch(var(--lch-blue-medium));
&::before {
background-color: oklch(var(--lch-blue-medium));
}
}
.attachment--csv &,
.attachment--numbers &,
.attachment--xls &,
.attachment--xlsx & {
color: oklch(var(--lch-green-dark));
background-color: oklch(var(--lch-green-lightest));
border-color: oklch(var(--lch-green-medium));
&::before {
background-color: oklch(var(--lch-green-medium));
}
}
}
.attachment__link {
--hover-size: 0;
color: var(--color-link);
text-decoration: underline;
}
/* Attachment junk gets wrapped in a paragraph causing unwanted space */
p:has(action-text-attachment) {
display: none;
+ * {
margin-block-start: 0;
}
}
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
}
/* Code */
:where(code, pre) {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
color: var(--color-ink);
font-family: var(--font-mono);
font-size: 0.85em;
font-weight: 500;
margin-block: var(--block-margin);
padding: 0.1em 0.3em;
&[data-language] {
border-radius: 0.5em;
display: block;
hyphens: none;
overflow-x: auto;
padding: 0.5lh 2ch;
tab-size: 2;
text-wrap: nowrap;
white-space: pre;
word-break: break-word;
/* Keywords and attributes */
.code-token__attr,
.token.atrule,
.token.attr,
.token.keyword {
color: var(--color-code-token__att);
}
/* Constants, booleans, numbers, properties, tags */
.code-token__property,
.token.boolean,
.token.constant,
.token.deleted,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: var(--color-code-token__property);
}
/* Strings, selectors, and built-in constructs */
.code-token__selector,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string {
color: var(--color-code-token__selector);
}
/* Comments and meta information */
.code-token__comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: var(--color-code-token__comment);
font-style: italic;
}
/* Operators and symbolic entities */
.code-token__operator,
.token.entity,
.token.operator,
.token.url {
color: var(--color-code-token__operator);
}
/* Functions and class names */
.code-token__function,
.token.class,
.token.class-name,
.token.function {
color: var(--color-code-token__function);
}
/* Variables, regex, namespaces, important */
.code-token__variable,
.token.important,
.token.namespace,
.token.regex,
.token.variable {
color: var(--color-code-token__variable);
}
/* Punctuation */
.code-token__punctuation,
.token.punctuation {
color: var(--color-code-token__punctuation);
}
}
}
}
}
+4
View File
@@ -162,6 +162,10 @@
--tray-item-margin: 0;
--tray-item-scale: 1;
}
.bubble {
display: none;
}
}
.tray__item--overflow {
+12
View File
@@ -0,0 +1,12 @@
module ExcerptHelper
def format_excerpt(content, length: 200)
return "" if content.blank?
text = content.to_plain_text
text = text.gsub(/^>\s*(.*)$/m, '> \1')
text = text.gsub(/^[-*]\s*(.*)$/m, '• \1')
text = text.gsub(/^\d+\.\s*(.*)$/m) { |m| m }
text = text.gsub(/\s+/, " ").strip
text.truncate(length)
end
end
+1 -1
View File
@@ -58,7 +58,7 @@ module NotificationsHelper
def comment_notification_body(event)
comment = event.eventable
"#{strip_tags(comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body_html).truncate(200)}"
"#{strip_tags(comment.body.to_s).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body.to_s).truncate(200)}"
end
def card_notification_title(card)
+3 -1
View File
@@ -2,4 +2,6 @@
import "@hotwired/turbo-rails"
import "initializers"
import "controllers"
import "house"
import "actiontext-lexical"
import "@rails/actiontext"
@@ -0,0 +1,13 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["textarea", "wrapper"]
connect() {
this.resize()
}
resize() {
this.wrapperTarget.setAttribute("data-autoresize-clone-value", this.textareaTarget.value)
}
}
@@ -30,9 +30,10 @@ export default class extends Controller {
async restoreContent() {
await nextFrame()
const savedContent = localStorage.getItem(this.keyValue)
let savedContent = localStorage.getItem(this.keyValue)
if (savedContent) {
savedContent = `<div>${savedContent}</div>` // temporary for old markdown saves
this.inputTarget.value = savedContent
this.#triggerChangeEvent(savedContent)
}
@@ -45,8 +46,8 @@ export default class extends Controller {
}
#triggerChangeEvent(newValue) {
if (this.inputTarget.tagName === "HOUSE-MD") {
this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', {
if (this.inputTarget.tagName === "LEXICAL-EDITOR") {
this.inputTarget.dispatchEvent(new CustomEvent('actiontext:change', {
bubbles: true,
detail: {
previousContent: '',
@@ -1,28 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
pasteFiles(event) {
const editor = event.target.closest("house-md")
if (!editor) return
const files = event.clipboardData?.files
if (!files?.length) return
event.preventDefault()
for (const file of files) {
const uploadEvent = new CustomEvent("house-md:before-upload", {
bubbles: true,
detail: { file },
cancelable: true
})
if (editor.dispatchEvent(uploadEvent)) {
const upload = document.createElement("house-md-upload")
upload.file = file
upload.uploadsURL = editor.dataset.uploadsUrl
editor.appendChild(upload)
}
}
}
}
@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { highlightAll } from "actiontext-lexical"
export default class extends Controller {
connect() {
highlightAll()
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ class Card < ApplicationRecord
has_many :comments, dependent: :destroy
has_one_attached :image, dependent: :purge_later
has_markdown :description
has_rich_text :description
before_save :set_default_title, if: :published?
+2 -2
View File
@@ -4,7 +4,7 @@ class Command::GoToCard < Command
validates_presence_of :card
def title
"Visit card '#{card.title}'"
"Visit card '#{card&.title}'"
end
def execute
@@ -13,6 +13,6 @@ class Command::GoToCard < Command
private
def card
user.accessible_cards.find(card_id)
user.accessible_cards.find_by_id(card_id)
end
end
+5 -1
View File
@@ -5,7 +5,7 @@ class Comment < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :reactions, dependent: :delete_all
has_markdown :body
has_rich_text :body
searchable_by :body_plain_text, using: :comments_search_index, as: :body
scope :chronologically, -> { order created_at: :asc, id: :desc }
@@ -19,6 +19,10 @@ class Comment < ApplicationRecord
end
private
def body_plain_text
body.to_plain_text
end
def watch_card_by_creator
card.watch_by creator
end
+4 -4
View File
@@ -14,7 +14,7 @@ module Mentions
end
def mentionable_content
markdown_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
end
private
@@ -32,12 +32,12 @@ module Mentions
collection.users
end
def markdown_associations
self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::Markdown }
def rich_text_associations
self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText }
end
def mentionable_content_changed?
markdown_associations.any? { send(it.name).content_previously_changed? }
rich_text_associations.any? { send(it.name).body_previously_changed? }
end
def create_mentions_later
@@ -0,0 +1,42 @@
<% if blob.representable? %>
<figure class="attachment attachment--preview attachment--<%= blob.filename.extension %>">
<%= render "active_storage/blobs/web/representation", blob: blob %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<span><%= caption %></span>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<% end %>
<span> · </span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<span> · </span>
<%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %>
<span>Download</span>
<% end %>
</figcaption>
</figure>
<% else %>
<div class="attachment attachment--file attachment--<%= blob.filename.extension -%>">
<%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__figure", download: blob.filename, title: "Download #{blob.filename}" do %>
<%= render "active_storage/blobs/web/representation", blob: blob %>
<% end %>
<div class="attachment__caption">
<div>
<% if caption = blob.try(:caption) %>
<strong><%= caption %></strong>
<% else %>
<strong class="attachment__name"><%= blob.filename %></strong>
<% end %>
</div>
<div>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<span> · </span>
<%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %>
<span>Download</span>
<% end %>
</div>
</div>
</div>
<% end %>
@@ -0,0 +1,19 @@
<% size = local_assigns[:in_gallery] ? [800, 600] : [1024, 768] %>
<% if blob.video? %>
<%= tag.video \
src: rails_blob_path(blob),
controls: true,
preload: :none,
width: "100%", height: "100%" %>
<% elsif blob.audio? %>
<audio controls="true" width="100%" preload="metadata">
<source src="<%= rails_blob_url(blob) %>" type="<%= blob.content_type %>">
</audio>
<% elsif blob.variable? %>
<%= image_tag url_for(blob.variant(loader: { n: -1 }, resize_to_limit: size)) %>
<% elsif blob.previewable? %>
<%= image_tag url_for(blob.preview(resize_to_limit: size)) %>
<% else %>
<span class="attachment__icon"><%= blob.filename.extension&.downcase.presence || "unknown" %></span>
<% end %>
+2 -2
View File
@@ -28,8 +28,8 @@
<% end %>
</div>
<div class="comment__body markdown-content txt-align-start">
<%= sanitize comment.body_html %>
<div class="comment__body rich-text-content txt-align-start" data-controller="syntax-highlight">
<%= comment.body %>
</div>
<%= render "cards/comments/reactions/reactions", comment: comment %>
+5 -5
View File
@@ -4,13 +4,13 @@
</figure>
<div class="comment__content flex-inline flex-column full-width">
<div class="comment__body markdown-content txt-align-start">
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form paste local-save",
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 paste->paste#pasteFiles" } do |form| %>
<%= form.markdown_area :body, class: "input comment__input", required: true, placeholder: new_comment_placeholder(card),
data: { local_save_target: "input", action: "house-md:change->local-save#save turbo:morph-element->local-save#restoreContent" } %>
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" } %>
<%= form.button class: "comment__submit btn btn--reversed flex-item-justify-start" do %>
<span>Post this comment</span>
<% end %>
+3 -3
View File
@@ -5,10 +5,10 @@
</figure>
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__body markdown-content txt-align-start">
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form paste", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } do |form| %>
<%= form.markdown_area :body, class: "input comment__input", required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %>
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) %>
<div class="flex gap-half justify-start">
<%= form.button class: "btn btn--reversed", type: :submit do %>
<span>Save changes</span>
+10 -10
View File
@@ -4,23 +4,23 @@
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>
</h1>
<div class="card__description markdown-content margin-block-half">
<%= sanitize card.description_html %>
<div class="card__description rich-text-content margin-block-half rich-text-content" data-controller="syntax-highlight">
<%= card.description %>
</div>
</div>
<% end %>
<% else %>
<%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "paste auto-save" } do |form| %>
<h1 class="card__title">
<%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "autoresize auto-save" } do |form| %>
<h1 class="card__title autoresize__wrapper" data-autoresize-target="wrapper" data-autoresize-clone-value="">
<%= form.text_area :title, placeholder: "Name it…",
class: "input input--textarea full-width borderless txt-align-start",
autofocus: card.title.blank?,
data: { action: "auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %>
class: "input input--textarea full-width borderless txt-align-start autoresize__textarea",
autofocus: card.title.blank?, rows: 1,
data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %>
</h1>
<div class="card__description markdown-content margin-block-start-half margin-block-end">
<%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
<div class="card__description rich-text-content margin-block-start-half margin-block-end">
<%= form.rich_textarea :description, class: "card-field__description",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "house-md: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 paste->paste#pasteFiles" } %>
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" } %>
</div>
<% end %>
<% end %>
+9 -10
View File
@@ -1,17 +1,16 @@
<%= turbo_frame_tag @card, :edit do %>
<%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "form paste" } do |form| %>
<%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "autoresize form" } do |form| %>
<h1 class="card__title">
<%= form.label :title, class: "flex flex-column align-center" do %>
<%= form.text_area :title, class: "input input--textarea full-width borderless txt-align-start card-field__title",
required: true, autofocus: true, placeholder: "Name it…",
rows: 1,
data: { action: "keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %>
<%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %>
<%= form.text_area :title, class: "input input--textarea full-width borderless txt-align-start card-field__title autoresize__textarea",
required: true, autofocus: true, placeholder: "Name it…", rows: 1,
data: { autoresize_target: "textarea", action: "input->autoresize#resize keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %>
<% end %>
</h1>
<div class="card__description markdown-content margin-block-start-half margin-block-end">
<%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
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 paste->paste#pasteFiles" } %>
<%= 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" } %>
</div>
<%= form.button "Save changes", type: :submit, class: "btn" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
+1 -1
View File
@@ -1,5 +1,5 @@
<%= render "events/event/layout", card: event.eventable.card, event: event do %>
<span class="txt-break overflow-line-clamp txt-tight-lines">
<%= strip_tags(event&.eventable.body_html).truncate(200) -%>
<%= format_excerpt(event&.eventable.body, length: 200) -%>
</span>
<% end %>
@@ -0,0 +1,3 @@
<div class="action-text-content">
<%= yield -%>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<%= page_title_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, interactive-widget=resizes-content">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="view-transition" content="same-origin">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
+3 -1
View File
@@ -5,9 +5,11 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@rails/request.js", to: "@rails--request.js" # @0.0.11
pin "house", to: "house.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
pin_all_from "app/javascript/initializers", under: "initializers"
pin "marked" # @15.0.11
pin "actiontext-lexical"
pin "@rails/activestorage", to: "activestorage.esm.js"
pin "@rails/actiontext", to: "actiontext.esm.js"
-7
View File
@@ -1,7 +0,0 @@
ActiveSupport.on_load :action_text_markdown do
require "markdown_renderer"
require "redcarpet/render_strip"
ActionText::Markdown.html_renderer = ->(content) { MarkdownRenderer.build.render(content) }
ActionText::Markdown.plain_text_renderer = Redcarpet::Markdown.new(Redcarpet::Render::StripDown)
end
@@ -0,0 +1,26 @@
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :action_text_rich_texts, id: primary_key_type do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end
Generated
+11 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_05_15_125505) do
ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -39,6 +39,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_15_125505) do
t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record"
end
create_table "action_text_rich_texts", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
+116 -78
View File
@@ -1,7 +1,7 @@
--- !ruby/object:ActiveRecord::ConnectionAdapters::SchemaCache
columns:
accesses:
- &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: collection_id
cast_type: &1 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
@@ -80,7 +80,7 @@ columns:
default_function:
collation:
comment:
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_id
cast_type: *1
@@ -118,13 +118,13 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: &14 !ruby/object:ActiveRecord::Type::Text
cast_type: &11 !ruby/object:ActiveRecord::Type::Text
true: t
false: f
precision:
scale:
limit:
sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: TEXT
type: :text
limit:
@@ -159,17 +159,30 @@ columns:
collation:
comment:
- *9
active_storage_attachments:
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: body
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- *5
- *6
- *10
- &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: blob_id
cast_type: &11 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
name: record_id
cast_type: &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
precision:
scale:
limit:
max: 9223372036854775808
min: -9223372036854775808
sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: bigint
type: :integer
limit:
@@ -180,19 +193,23 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- *13
- *9
active_storage_attachments:
- &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_id
cast_type: *11
sql_type_metadata: *12
name: blob_id
cast_type: *14
sql_type_metadata: *15
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *10
- *16
- *13
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -208,8 +225,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: byte_size
cast_type: *11
sql_type_metadata: *12
cast_type: *14
sql_type_metadata: *15
'null': false
default:
default_function:
@@ -247,7 +264,7 @@ columns:
collation:
comment:
- *6
- &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: key
cast_type: *7
@@ -260,8 +277,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: metadata
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
@@ -278,7 +295,7 @@ columns:
collation:
comment:
active_storage_variant_records:
- *16
- *17
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -292,7 +309,7 @@ columns:
comment:
ar_internal_metadata:
- *5
- *17
- *18
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -305,7 +322,7 @@ columns:
collation:
comment:
assignees_filters:
- &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: assignee_id
cast_type: *1
@@ -315,7 +332,7 @@ columns:
default_function:
collation:
comment:
- &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: filter_id
cast_type: *1
@@ -326,7 +343,7 @@ columns:
collation:
comment:
assigners_filters:
- &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: assigner_id
cast_type: *1
@@ -336,11 +353,11 @@ columns:
default_function:
collation:
comment:
- *18
assignments:
- *19
assignments:
- *20
- &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- *21
- &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: card_id
cast_type: *1
@@ -368,14 +385,14 @@ columns:
- *6
- *9
card_goldnesses:
- *21
- *22
- *5
- *6
- *9
cards:
- *22
- *23
- *5
- &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: creator_id
cast_type: *1
@@ -428,14 +445,14 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: status
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': false
default: creating
default_function:
collation:
comment:
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -461,7 +478,7 @@ columns:
comment:
- *9
closures:
- *21
- *22
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -489,11 +506,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
cast_type: &32 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -507,15 +524,15 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_close_period
cast_type: *11
sql_type_metadata: *12
cast_type: *14
sql_type_metadata: *15
'null': true
default:
default_function:
collation:
comment:
- *5
- *23
- *24
- *6
- *10
- *9
@@ -530,18 +547,18 @@ columns:
collation:
comment:
collections_filters:
- *22
- *18
- *23
- *19
commands:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: data
cast_type: &25 !ruby/object:ActiveRecord::Type::Json
cast_type: &26 !ruby/object:ActiveRecord::Type::Json
precision:
scale:
limit:
sql_type_metadata: &26 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: json
type: :json
limit:
@@ -556,8 +573,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: line
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
@@ -584,16 +601,16 @@ columns:
collation:
comment:
- *9
- *24
- *25
comments:
- *21
- *22
- *5
- *23
- *24
- *6
- *9
creators_filters:
- *23
- *18
- *24
- *19
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -605,9 +622,9 @@ columns:
default_function:
collation:
comment:
- *22
- *5
- *23
- *5
- *24
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: eventable_id
@@ -632,8 +649,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: particulars
cast_type: *25
sql_type_metadata: *26
cast_type: *26
sql_type_metadata: *27
'null': true
default: "{}"
default_function:
@@ -642,12 +659,12 @@ columns:
- *9
filters:
- *5
- *23
- *24
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: fields
cast_type: *25
sql_type_metadata: *26
cast_type: *26
sql_type_metadata: *27
'null': false
default: "{}"
default_function:
@@ -666,7 +683,7 @@ columns:
comment:
- *9
filters_stages:
- *18
- *19
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: stage_id
@@ -678,8 +695,8 @@ columns:
collation:
comment:
filters_tags:
- *18
- &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- *19
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *1
@@ -712,7 +729,7 @@ columns:
default_function:
collation:
comment:
- &27 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: source_id
cast_type: *1
@@ -722,7 +739,7 @@ columns:
default_function:
collation:
comment:
- &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: source_type
cast_type: *7
@@ -756,16 +773,16 @@ columns:
default_function:
collation:
comment:
- *27
- *28
- *29
- *9
- *24
- *25
pins:
- *21
- *22
- *5
- *6
- *9
- *24
- *25
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -882,24 +899,24 @@ columns:
default_function:
collation:
comment:
- *24
- *25
taggings:
- *21
- *22
- *5
- *6
- *29
- *30
- *9
tags:
- *5
- *6
- *30
- *31
- *9
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *31
sql_type_metadata: *32
cast_type: *32
sql_type_metadata: *33
'null': false
default: true
default_function:
@@ -940,16 +957,16 @@ columns:
comment:
- *9
watches:
- *21
- *22
- *5
- *6
- *9
- *24
- *25
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *31
sql_type_metadata: *32
cast_type: *32
sql_type_metadata: *33
'null': false
default: true
default_function:
@@ -989,6 +1006,7 @@ primary_keys:
accesses: id
accounts: id
action_text_markdowns: id
action_text_rich_texts: id
active_storage_attachments: id
active_storage_blobs: id
active_storage_variant_records: id
@@ -1027,6 +1045,7 @@ data_sources:
accesses: true
accounts: true
action_text_markdowns: true
action_text_rich_texts: true
active_storage_attachments: true
active_storage_blobs: true
active_storage_variant_records: true
@@ -1131,6 +1150,25 @@ indexes:
nulls_not_distinct:
comment:
valid: true
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: action_text_rich_texts
name: index_action_text_rich_texts_uniqueness
unique: true
columns:
- record_type
- record_id
- name
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
active_storage_attachments:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: active_storage_attachments
@@ -2161,4 +2199,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250515125505
version: 20250523084734
-81
View File
@@ -1,81 +0,0 @@
require "rouge/plugins/redcarpet"
class MarkdownRenderer < Redcarpet::Render::HTML
include Rouge::Plugins::Redcarpet
def self.build
Redcarpet::Markdown.new MarkdownRenderer.new(filter_html: false),
autolink: true, highlight: true, no_intra_emphasis: true,
fenced_code_blocks: true, lax_spacing: true, strikethrough: true, tables: true
end
def initialize(...)
super
@id_counts = Hash.new 0
end
def header(text, header_level)
unique_id(text).then do |id|
<<~HTML.chomp
<h#{header_level} id="#{id}">
#{text} <a href="##{id}" class="heading__link" aria-hidden="true">#</a>
</h#{header_level}>
HTML
end
end
def image(url, title, alt_text)
<<~HTML.chomp
<a title="#{title}" href="#{url}" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="#{url}?disposition=attachment">
<img src="#{url}" alt="#{alt_text}">
</a>
HTML
end
def code_block(code, language)
block_code(code, language) # call Rouge Redcarpet plugin
end
def link(url, title, content)
return video_tag(url, title) if video_url?(url)
attributes = { href: url }
attributes[:title] = title if title
attributes["data-turbo-frame"] = "_top"
"<a #{html_attributes(attributes)}>#{content}</a>"
end
def autolink(url, link_type)
attributes = { href: url }
attributes["data-turbo-frame"] = "_top"
"<a #{html_attributes(attributes)}>#{url}</a>"
end
private
attr_reader :id_counts
def html_attributes(attributes)
attributes.map { |key, value| %Q(#{key}="#{value}") }.join(" ")
end
def unique_id(text)
text.parameterize.then do |base_id|
id_counts[base_id] += 1
id_counts[base_id] > 1 ? "#{base_id}-#{id_counts[base_id]}" : base_id
end
end
def video_url?(url)
File.extname(url).downcase.match?(/\.(mp4|webm|ogg|mov|avi|mkv)/)
end
def video_tag(url, title)
<<~HTML.chomp
<video controls title="#{title}" width="1024">
<source src="#{url}" type="video/#{File.extname(url).delete('.')}">
</video>
HTML
end
end
-47
View File
@@ -1,47 +0,0 @@
module ActionText
module HasMarkdown
extend ActiveSupport::Concern
class_methods do
def has_markdown(name, strict_loading: strict_loading_by_default)
class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
markdown_#{name} || build_markdown_#{name}
end
def #{name}_html
#{name}.to_html
end
def #{name}_plain_text
#{name}.to_plain_text
end
def #{name}?
markdown_#{name}.present?
end
def #{name}=(content)
self.#{name}.content = content
end
CODE
has_one :"markdown_#{name}", -> { where(name: name) },
class_name: "ActionText::Markdown", as: :record, inverse_of: :record, autosave: true, dependent: :destroy,
strict_loading: strict_loading
scope :"with_markdown_#{name}", -> { includes("markdown_#{name}") }
end
end
def safe_markdown_attribute(name)
if self.class.reflect_on_association("markdown_#{name}")&.klass == ActionText::Markdown
public_send(name)
end
end
end
end
ActiveSupport.on_load :active_record do
include ActionText::HasMarkdown
end
-44
View File
@@ -1,44 +0,0 @@
module ActionText
class Markdown < Record
mattr_accessor :html_renderer
mattr_accessor :plain_text_renderer
belongs_to :record, polymorphic: true, touch: true
def to_html
to_unsafe_html.html_safe
end
def to_unsafe_html
if html_renderer.respond_to? :call
html_renderer.call content
else
html_renderer.render content
end
end
def to_plain_text
if plain_text_renderer.respond_to? :call
plain_text_renderer.call content
else
plain_text_renderer.render content
end
end
end
end
module ActionText::Markdown::Uploads
extend ActiveSupport::Concern
included do
has_many_attached :uploads, dependent: :destroy
end
end
ActiveSupport.on_load :active_storage_attachment do
class ActionText::Markdown
include ActionText::Markdown::Uploads
end
end
ActiveSupport.run_load_hooks :action_text_markdown, ActionText::Markdown
-36
View File
@@ -1,36 +0,0 @@
module ActionText
module TagHelper
def markdown_area(record, name, value: nil, data: {}, **options)
field_name = "#{record.class.model_name.param_key}[#{name}]"
value = record.safe_markdown_attribute(name).content.to_s if value.nil?
data = data.reverse_merge! uploads_url: uploads_url(format: "json")
tag.house_md value, name: field_name, data: data, **options
end
def house_toolbar(**options, &block)
tag.house_md_toolbar(**options, &block)
end
def house_toolbar_button(action, **options, &block)
tag.button title: action.to_s.humanize, data: { "house-md-action": action }, **options, &block
end
def house_toolbar_file_upload_button(name: "upload", title: "Upload File", **options, &block)
tag.label title: title, **options do
safe_join [
file_field_tag(name, data: { "house-md-toolbar-file-picker": true }, style: "display: none;"),
capture(&block)
]
end
end
end
end
module ActionView::Helpers
class FormBuilder
def markdown_area(method, **options)
@template.markdown_area(@object, method, **options)
end
end
end
-32
View File
@@ -1,32 +0,0 @@
module ActiveStorage::Sluggable
extend ActiveSupport::Concern
included do
before_create :set_slug
end
def slug_url(host: ActiveStorage::Current.url_options[:host], port: ActiveStorage::Current.url_options[:port])
Rails.application.routes.url_helpers.upload_url(slug, host: host, port: port)
end
private
def set_slug
self.slug = "#{slug_basename}-#{SecureRandom.alphanumeric(6)}.#{slug_extension}"
end
def slug_basename
File.basename(slug_filename, ".*").parameterize
end
def slug_extension
File.extname(slug_filename).delete(".").parameterize
end
def slug_filename
slug.presence || filename.to_s
end
end
ActiveSupport.on_load :active_storage_attachment do
ActiveStorage::Attachment.include ActiveStorage::Sluggable
end
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "redcarpet"
require "nokogiri"
class ActionText::Markdown < ApplicationRecord
belongs_to :record, polymorphic: true
end
class MarkdownToActionTextConverter
ATTACHMENT_URL_REGEX = %r{/u/(?<slug>[^\/\s\)]+)}
def initialize(html)
@doc = Nokogiri::HTML::DocumentFragment.parse(html)
@attachments = []
end
def convert
process_images
process_links
[ @doc.to_html, @attachments ]
end
private
def process_images
@doc.css("img").each do |img|
src = img["src"].presence
if src && match = src.match(ATTACHMENT_URL_REGEX)
if (attachment = find_attachment(match[:slug]))
img.replace(build_attachment_node(attachment))
@attachments << attachment
end
end
end
end
def process_links
@doc.css("a").each do |link|
href = link["href"].presence
if href && match = href.match(ATTACHMENT_URL_REGEX)
if (attachment = find_attachment(match[:slug]))
link.replace(build_attachment_node(attachment))
@attachments << attachment
end
end
end
end
def build_attachment_node(attachment)
html = ActionText::Attachment.from_attachable(attachment).to_html
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
node = fragment.at_css("action-text-attachment")
node["url"] = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true)
fragment
end
def find_attachment(slug)
ActiveStorage::Attachment.find_by(slug: slug)
end
end
class RedcarpetRenderer
def self.render(markdown)
renderer = Redcarpet::Render::HTML.new
markdowner = Redcarpet::Markdown.new(renderer,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
superscript: true,
)
markdowner.render(markdown.to_s)
end
end
def process_all(klass, field)
klass.find_each do |record|
markdown = ActionText::Markdown.find_by(record: record, name: field)
next unless markdown
puts "markdown.id=#{markdown.id}"
next unless markdown.record
html = RedcarpetRenderer.render(markdown.content.to_s)
converter = MarkdownToActionTextConverter.new(html)
rich_text_html, attachments = converter.convert
rich_text = ActionText::RichText.create!(
name: markdown.name,
record: markdown.record,
body: rich_text_html
)
attachments.each do |attachment|
attachment.update!(record: rich_text)
end
puts "✓ Created rich text for #{markdown.record_type}##{markdown.record_id} (#{markdown.name})"
rescue => e
warn "✗ Failed to process markdown ##{markdown.id}: #{e.class} - #{e.message}"
end
end
ApplicationRecord.with_each_tenant do |tenant|
puts "Processing tenant: #{tenant}"
ActionText::RichText.delete_all
process_all(Card, :description)
process_all(Comment, :body)
end
+1 -19
View File
@@ -214,9 +214,6 @@ class EnactTenanting
assert_count Tagging, Tagging.count
assert_count Watch, Watch.count
# we're only copying the markdown records that are still accessible.
assert_count ActionText::Markdown, Comment.count
assert_count Event, Event.count
assert_count Notification, Notification.count
@@ -228,22 +225,7 @@ class EnactTenanting
end
def update_action_text_urls
update_counter["ActionText::Markdown"] = 0
TENANTS.each do |tenant|
tenanted_domain = sprintf(TENANTED_DOMAIN, tenant: tenant)
ApplicationRecord.with_tenant(tenant) do
ActionText::Markdown.all.each do |markdown|
content = markdown.content
next unless content =~ %r{#{CURRENT_DOMAIN}/u/}
content.gsub!(%r{#{CURRENT_DOMAIN}/u/}, "#{tenanted_domain}/u/")
markdown.update_column :content, content
update_counter["ActionText::Markdown"] += 1
end
end
end
raise "No more markdown"
end
def hardlink_active_storage
+3 -3
View File
@@ -100,9 +100,9 @@ ApplicationRecord.with_each_tenant do |tenant|
end
# Update action_text_markdowns table (polymorphic relationship)
ActionText::Markdown.where(record_type: "Card").find_each do |markdown|
if id_mapping[markdown.record_id]
markdown.update_column(:record_id, id_mapping[markdown.record_id])
ActionText::RichText.where(record_type: "Card").find_each do |rich_text|
if id_mapping[rich_text.record_id]
rich_text.update_column(:record_id, id_mapping[rich_text.record_id])
end
end
+3 -3
View File
@@ -73,9 +73,9 @@ ApplicationRecord.with_each_tenant do |tenant|
end
# Update action_text_markdowns table (polymorphic relationship)
ActionText::Markdown.where(record_type: "Collection").find_each do |markdown|
if id_mapping[markdown.record_id]
markdown.update_column(:record_id, id_mapping[markdown.record_id])
ActionText::RichText.where(record_type: "Collection").find_each do |rich_text|
if id_mapping[rich_text.record_id]
rich_text.update_column(:record_id, id_mapping[rich_text.record_id])
end
end
@@ -17,11 +17,11 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: "I've changed my mind" } }
assert_response :success
assert_equal "I've changed my mind", comments(:logo_agreement_kevin).reload.body.content
assert_action_text "I've changed my mind", comments(:logo_agreement_kevin).reload.body
end
test "update another user's comment" do
assert_no_changes "comments(:logo_agreement_jz).body.content" do
assert_no_changes -> { comments(:logo_agreement_jz).reload.body.to_s } do
put card_comment_path(cards(:logo), comments(:logo_agreement_jz)), params: { comment: { body: "I've changed my mind" } }
end
+1 -1
View File
@@ -46,7 +46,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_equal "moon.jpg", card.image.filename.to_s
assert_equal [ tags(:mobile) ], card.tags
assert_equal "Something more in-depth", card.description_plain_text.strip
assert_equal "Something more in-depth", card.description.to_plain_text.strip
end
test "users can only see cards in collections they have access to" do
@@ -1,25 +0,0 @@
require "test_helper"
class UploadsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
assert_changes -> { ActiveStorage::Attachment.count }, 1 do
post uploads_path(format: "json"), params: { file: fixture_file_upload("moon.jpg", "image/jpeg") }, as: :xhr
end
assert_response :success
assert_equal ActiveStorage::Attachment.last.slug_url(host: "#{ApplicationRecord.current_tenant}.example.com", port: nil), response.parsed_body["fileUrl"]
assert_equal "image/jpeg", response.parsed_body["mimetype"]
assert_equal "moon.jpg", response.parsed_body["fileName"]
end
test "show" do
accounts("37s").uploads.attach fixture_file_upload("moon.jpg", "image/jpeg")
get upload_path(slug: accounts("37s").uploads.last.slug)
assert_response :redirect
assert_match /\/rails\/active_storage\/.*\/moon\.jpg/, @response.redirect_url
end
end
@@ -1,14 +1,16 @@
logo_agreement_jz:
record: logo_agreement_jz (Comment)
name: body
content: I agree.
body: I agree.
logo_agreement_kevin:
record: logo_agreement_kevin (Comment)
name: body
content: Same, lets do it.
body: Same, lets do it.
layout_overflowing_david:
record: layout_overflowing_david (Comment)
name: body
content: The text is overflowing the container.
body: The text is overflowing the container.
+1 -1
View File
@@ -22,7 +22,7 @@ module ActiveSupport
fixtures :all
include ActiveJob::TestHelper
include CardTestHelper, ChangeTestHelper, SessionTestHelper
include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
end
end
@@ -0,0 +1,21 @@
module ActionTextTestHelper
def assert_action_text(expected_html, content)
assert_equal_html <<~HTML, content.to_s
<div class="action-text-content">#{expected_html}</action-text-content>
HTML
end
def assert_equal_html(expected, actual)
assert_equal normalize_html(expected), normalize_html(actual)
end
def normalize_html(html)
Nokogiri::HTML.fragment(html).tap do |fragment|
fragment.traverse do |node|
if node.text?
node.content = node.text.squish
end
end
end.to_html.strip
end
end
File diff suppressed because one or more lines are too long