diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..5008ddfcf Binary files /dev/null and b/.DS_Store differ diff --git a/Dockerfile b/Dockerfile index e0712a39a..64436b2bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Gemfile b/Gemfile index 1d8d39cd7..8c1219952 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 5b9940aad..4df508aa2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index aad4508fc..5b2a966af 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -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); diff --git a/app/assets/stylesheets/actiontext-lexical.css b/app/assets/stylesheets/actiontext-lexical.css new file mode 100644 index 000000000..c0ab455a5 --- /dev/null +++ b/app/assets/stylesheets/actiontext-lexical.css @@ -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); + } + } +} diff --git a/app/assets/stylesheets/autoresize.css b/app/assets/stylesheets/autoresize.css new file mode 100644 index 000000000..ac16e04a4 --- /dev/null +++ b/app/assets/stylesheets/autoresize.css @@ -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; + } + } +} diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 4e3c237f7..53315b8a6 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -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; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 4af194258..36d87e30d 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -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))); - } - } } diff --git a/app/assets/stylesheets/markdown-content.css b/app/assets/stylesheets/markdown-content.css deleted file mode 100644 index 81589501b..000000000 --- a/app/assets/stylesheets/markdown-content.css +++ /dev/null @@ -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; - } - } - } -} diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css new file mode 100644 index 000000000..52254f84d --- /dev/null +++ b/app/assets/stylesheets/rich-text-content.css @@ -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); + } + } + } + } +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 947a4094a..2267ea20d 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -162,6 +162,10 @@ --tray-item-margin: 0; --tray-item-scale: 1; } + + .bubble { + display: none; + } } .tray__item--overflow { diff --git a/app/helpers/excerpt_helper.rb b/app/helpers/excerpt_helper.rb new file mode 100644 index 000000000..9ae0d3274 --- /dev/null +++ b/app/helpers/excerpt_helper.rb @@ -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 diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 57fd63eff..8dab7c110 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -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) diff --git a/app/javascript/application.js b/app/javascript/application.js index c2917aa3e..36d861cc7 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,4 +2,6 @@ import "@hotwired/turbo-rails" import "initializers" import "controllers" -import "house" + +import "actiontext-lexical" +import "@rails/actiontext" diff --git a/app/javascript/controllers/autoresize_controller.js b/app/javascript/controllers/autoresize_controller.js new file mode 100644 index 000000000..02b6cc501 --- /dev/null +++ b/app/javascript/controllers/autoresize_controller.js @@ -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) + } +} diff --git a/app/javascript/controllers/local_save_controller.js b/app/javascript/controllers/local_save_controller.js index 5d9b89a72..5f9fc83a3 100644 --- a/app/javascript/controllers/local_save_controller.js +++ b/app/javascript/controllers/local_save_controller.js @@ -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 = `
<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>
-+ <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "autoresize auto-save" } do |form| %> +
- <%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
+
+ <%= 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" } %>
<% end %>
<% end %>
diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb
index 499563bb4..6a8d37e13 100644
--- a/app/views/cards/edit.html.erb
+++ b/app/views/cards/edit.html.erb
@@ -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| %>
- <%= 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" } %>
<%= 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 %>
diff --git a/app/views/events/event/_comment.html.erb b/app/views/events/event/_comment.html.erb
index 22f4596e8..ed0cbb79c 100644
--- a/app/views/events/event/_comment.html.erb
+++ b/app/views/events/event/_comment.html.erb
@@ -1,5 +1,5 @@
<%= render "events/event/layout", card: event.eventable.card, event: event do %>
- <%= strip_tags(event&.eventable.body_html).truncate(200) -%>
+ <%= format_excerpt(event&.eventable.body, length: 200) -%>
<% end %>
diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb
new file mode 100644
index 000000000..0dec72775
--- /dev/null
+++ b/app/views/layouts/action_text/contents/_content.html.erb
@@ -0,0 +1,3 @@
+
+ <%= yield -%>
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 0e04cbca9..b667bfb47 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -3,7 +3,7 @@
<%= page_title_tag %>
-
+
diff --git a/config/importmap.rb b/config/importmap.rb
index 65dc7629c..2abe3a805 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -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"
diff --git a/config/initializers/markdown.rb b/config/initializers/markdown.rb
deleted file mode 100644
index 04d5c8e78..000000000
--- a/config/initializers/markdown.rb
+++ /dev/null
@@ -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
diff --git a/db/migrate/20250523084734_create_action_text_tables.action_text.rb b/db/migrate/20250523084734_create_action_text_tables.action_text.rb
new file mode 100644
index 000000000..c1c0a9d8c
--- /dev/null
+++ b/db/migrate/20250523084734_create_action_text_tables.action_text.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 834b2bcc9..098674f3a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index fbaacdc9c..6cd19a67c 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -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
diff --git a/lib/markdown_renderer.rb b/lib/markdown_renderer.rb
deleted file mode 100644
index e90e86fb4..000000000
--- a/lib/markdown_renderer.rb
+++ /dev/null
@@ -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
-
- #{text} #
-
- HTML
- end
- end
-
- def image(url, title, alt_text)
- <<~HTML.chomp
-
-
-
- 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"
-
- "#{content}"
- end
-
- def autolink(url, link_type)
- attributes = { href: url }
- attributes["data-turbo-frame"] = "_top"
-
- "#{url}"
- 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
-
- HTML
- end
-end
diff --git a/lib/rails_ext/action_text_has_markdown.rb b/lib/rails_ext/action_text_has_markdown.rb
deleted file mode 100644
index b792657d5..000000000
--- a/lib/rails_ext/action_text_has_markdown.rb
+++ /dev/null
@@ -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
diff --git a/lib/rails_ext/action_text_markdown.rb b/lib/rails_ext/action_text_markdown.rb
deleted file mode 100644
index cd19fa001..000000000
--- a/lib/rails_ext/action_text_markdown.rb
+++ /dev/null
@@ -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
diff --git a/lib/rails_ext/action_text_tag_helper.rb b/lib/rails_ext/action_text_tag_helper.rb
deleted file mode 100644
index d662da7b2..000000000
--- a/lib/rails_ext/action_text_tag_helper.rb
+++ /dev/null
@@ -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
diff --git a/lib/rails_ext/active_storage_sluggable.rb b/lib/rails_ext/active_storage_sluggable.rb
deleted file mode 100644
index d4d4be335..000000000
--- a/lib/rails_ext/active_storage_sluggable.rb
+++ /dev/null
@@ -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
diff --git a/script/convert_markdowns_to_rich_text.rb b/script/convert_markdowns_to_rich_text.rb
new file mode 100755
index 000000000..e093730f6
--- /dev/null
+++ b/script/convert_markdowns_to_rich_text.rb
@@ -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/(?[^\/\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
diff --git a/script/migrate-to-multi-tenant b/script/migrate-to-multi-tenant
index 8d035f37e..79db5c0e0 100755
--- a/script/migrate-to-multi-tenant
+++ b/script/migrate-to-multi-tenant
@@ -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
diff --git a/script/reset_cards_ids.rb b/script/reset_cards_ids.rb
index 19305b5f2..65c556283 100644
--- a/script/reset_cards_ids.rb
+++ b/script/reset_cards_ids.rb
@@ -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
diff --git a/script/reset_collections_ids.rb b/script/reset_collections_ids.rb
index 142e9e2a9..cf1d84028 100644
--- a/script/reset_collections_ids.rb
+++ b/script/reset_collections_ids.rb
@@ -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
diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb
index 690cf0656..76ff0f6fa 100644
--- a/test/controllers/cards/comments_controller_test.rb
+++ b/test/controllers/cards/comments_controller_test.rb
@@ -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
diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb
index 5cb3a6582..75ba45147 100644
--- a/test/controllers/cards_controller_test.rb
+++ b/test/controllers/cards_controller_test.rb
@@ -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
diff --git a/test/controllers/uploads_controller_test.rb b/test/controllers/uploads_controller_test.rb
deleted file mode 100644
index 3f5f54af5..000000000
--- a/test/controllers/uploads_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/test/fixtures/action_text/markdowns.yml b/test/fixtures/action_text/rich_texts.yml
similarity index 69%
rename from test/fixtures/action_text/markdowns.yml
rename to test/fixtures/action_text/rich_texts.yml
index 519cd408b..9a86c3b30 100644
--- a/test/fixtures/action_text/markdowns.yml
+++ b/test/fixtures/action_text/rich_texts.yml
@@ -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, let’s do it.
+ body: Same, let’s 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.
+
+
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 6e27d82fa..4e0a9fcee 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -22,7 +22,7 @@ module ActiveSupport
fixtures :all
include ActiveJob::TestHelper
- include CardTestHelper, ChangeTestHelper, SessionTestHelper
+ include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
end
end
diff --git a/test/test_helpers/action_text_test_helper.rb b/test/test_helpers/action_text_test_helper.rb
new file mode 100644
index 000000000..09a26fceb
--- /dev/null
+++ b/test/test_helpers/action_text_test_helper.rb
@@ -0,0 +1,21 @@
+module ActionTextTestHelper
+ def assert_action_text(expected_html, content)
+ assert_equal_html <<~HTML, content.to_s
+ #{expected_html}
+ 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
diff --git a/vendor/javascript/house.min.js b/vendor/javascript/house.min.js
deleted file mode 100644
index c81e324e5..000000000
--- a/vendor/javascript/house.min.js
+++ /dev/null
@@ -1 +0,0 @@
-function t(t){return"string"==typeof t}function e(t,e,n=null,s=!1){return t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:n,cancelable:s}))}class n extends HTMLElement{constructor(){super()}connectedCallback(){this.#t(),this.addEventListener("click",this.#e),this.#n?.addEventListener("change",this.#s)}disconnectedCallback(){this.#n?.removeEventListener("change",this.#s),this.removeEventListener("click",this.#e)}get#n(){return this.querySelector("[data-house-md-toolbar-file-picker]")}#t(){this.#i()&&(this.innerHTML=this.#r())}#e=t=>{const n=t.target.closest("[data-house-md-action]")?.dataset?.houseMdAction;n&&(t.preventDefault(),e(this,"house-md:toolbar-action",{houseMdAction:n}))};#s=t=>{for(const n of t.target.files)e(this,"house-md:toolbar-action",{houseMdAction:"uploadFile",file:n})};#i(){return 0===this.children.length}#r(){return'\n \n \n \n \n \n \n \n '}}customElements.define("house-md-toolbar",n);class s{start=0;end=0;static fromDOMRange(t,e){const{startOffset:n,startContainer:r,endOffset:o,endContainer:l}=t,a=i(e,r,n);if(t.collapsed)return new s(e,a,a);const c=i(e,l,o);return new s(e,a,c)}constructor(t,e,n){this.container=t,this.start=e,this.end=n}toDOMRange(){const t=document.createRange();t.setStart(this.container,1),t.setEnd(this.container,this.container.childNodes.length);const e=document.createTreeWalker(this.container,NodeFilter.SHOW_TEXT);let n=0,s=!1,i=!1;for(;e.nextNode()&&(!s||!i);){const r=e.currentNode.textContent.length;n+r>=this.start&&!s&&(s=!0,t.setStart(e.currentNode,this.start-n)),n+r>=this.end&&!i&&(i=!0,t.setEnd(e.currentNode,this.end-n)),n+=r}return t}isEqual(t){return this.container===t.container&&this.start===t.start&&this.end===t.end}}function i(t,e,n){return e.nodeType===Node.ELEMENT_NODE?function(t,e,n){const s=e.childNodes[n];let i=0;const r=document.createTreeWalker(t);for(;r.nextNode();){if(r.currentNode===s)return i;r.currentNode.nodeType===Node.TEXT_NODE&&(i+=r.currentNode.textContent.length)}return i}(t,e,n):function(t,e,n){const s=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;s.nextNode();){if(s.currentNode===e)return n;n+=s.currentNode.textContent.length}return n}(t,e,n)}class r{#o;#l;#a;constructor(t){this.#o=t,this.#l=new s(this.contentElement,0,0),this.#a=""}current(){return{start:this.start,end:this.end,selection:this.#a}}currentLine(){return this.lineAt(this.end)}previousLine(){const{content:t}=this.#o,e=Math.max(t.lastIndexOf("\n",this.start-1),0);return this.lineAt(e)}lineAt(t){const{content:e}=this.#o,n=Math.max(e.lastIndexOf("\n",t-1)+1,0),s=e.indexOf("\n",t),i=-1===s?e.length:s+1;return{start:n,end:i,content:e.slice(n,i)}}select({start:t,end:e}){this.documentRange=new s(this.contentElement,t,e);const n=this.documentRange.toDOMRange();if(n){const t=window.getSelection();t.removeAllRanges(),t.addRange(n),this.update()}else console.error("Failed to create range",{start:t,end:e})}update=()=>{const t=document.getSelection()?.getRangeAt(0),n=this.#l;t.intersectsNode(this.contentElement)&&(this.#l=s.fromDOMRange(t,this.contentElement),this.#a=this.#o.content.slice(this.start,this.end)),this.#l.isEqual(n)||e(this.element,"house-md:selectionchange",{start:this.start,end:this.end})};get start(){return this.#l.start}get end(){return this.#l.end}get element(){return this.#o.element}get contentElement(){return this.#o.contentElement}get isEmpty(){return this.start===this.end}get isMultiline(){return this.current().selection.includes("\n")}get isBold(){return this.#c("strong")}get isItalic(){return this.#c("em")}get isStrikethrough(){return this.#c("s")}get isLink(){return this.#c(".link")}get isCode(){return this.#c(".code")}get isQuote(){return this.#c(".quote")}get isBulletList(){return this.#c(".ul-li")}get isNumberList(){return this.#c(".ol-li")}#c(t){let e=window.getSelection().getRangeAt(0).endContainer;if(e.nodeType===Node.TEXT_NODE&&(e=e.parentElement),this.contentElement.contains(e))return!!e.closest(t)}}class o{static saveInterval=1e3;constructor(t=500){this.maxSize=t,this.undoStack=[],this.redoStack=[]}undo(t){const e=this.undoStack.pop();return e?(this.redoStack.push(t),e):{}}redo(t){const e=this.redoStack.pop();return e?(this.undoStack.push(t),e):{}}add=function(t){let e;return(...n)=>{e||(t(...n),e=setTimeout((()=>{e=null}),o.saveInterval))}}((t=>{this.undoStack.push(t),this.redoStack=[],this.undoStack.length>this.maxSize&&this.undoStack.shift()}))}function l(t){let e=t;return e=function(t){return t=t.replaceAll(/&/g,"&"),t=t.replaceAll(//g,">"),t=t.replaceAll(/"/g,"""),t=t.replaceAll(/'/g,"'"),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/(?***$1***"),t=t.replaceAll(/(?___$1___"),t}(t),t=function(t){return t=t.replaceAll(/(?**$1**"),t=t.replaceAll(/(?__$1__"),t}(t),t=function(t){return t=t.replaceAll(/(?*$1*"),t=t.replaceAll(/(?_$1_"),t}(t),t=function(t){return t.replaceAll(/~~(.*)?~~/gm," ",this.contentElement.classList.toggle("house-md-content-empty",this.isEmpty)}get isEmpty(){return!this.content.trim()}get contentElement(){return this.element.contentWrapper}get currentSelection(){return this.selection.current()}get currentLine(){return this.selection.currentLine()}#p(t,e,n=e){t?this.#w(e,n):this.#E(e,n)}#E(t,e=t){const{start:n,end:s,selection:i}=this.currentSelection,r=`${t}${i}${e}`;this.replaceText(r,n,s),this.selection.select({start:n,end:n+i.length+t.length+e.length})}#w(t,e=t){const{start:n,end:s,selection:i}=this.currentSelection;i.startsWith(t)&&i.endsWith(e)&&(this.replaceText("",s-e.length,s),this.replaceText("",n,n+t.length),this.selection.select({start:n,end:s-t.length-e.length}))}#v(){const{start:t,end:e,selection:n}=this.currentSelection,s=this.selection.isEmpty,i=s?"title":n;this.replaceText(`[${i}](url)`,t,e),s?this.selection.select({start:t+1,end:t+i.length+1}):this.selection.select({start:t+n.length+3,end:t+n.length+6})}#g(){const{start:t,end:e,selection:n}=this.currentSelection,s=n.replace(/\[(.*)\]\(.*\)/,"$1");this.replaceText(s,t,e)}#u(){this.#T.match(/^-\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText("- ",this.currentSelection.end+1,this.currentSelection.end+1))}#m(){this.#T.match(/^\d+\.\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText(`${this.#L+1}. `,this.currentSelection.end+1,this.currentSelection.end+1))}#b(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;this.replaceText(t,s.start,s.start),this.selection.select({start:e+t.length,end:n+t.length})}#f(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;if(t instanceof RegExp){const e=s.content.match(t);if(!e)return;t=e[0]}s.content.startsWith(t)&&(this.deleteText(s.start,s.start+t.length),this.selection.select({start:e-t.length,end:n-t.length}))}#h(){this.history.add(this.#x)}#d(t,n){const s=this.content;this.content=t,this.element.value=t,this.selection.select(n),e(this.element,"house-md:change",{previousContent:s,newContent:t})}get#x(){const{start:t,end:e}=this.currentSelection;return{content:this.content,start:t,end:e}}get#T(){return this.currentLine.content}get#L(){const t=this.selection.previousLine().content.match(/^(\d+). /);return t?parseInt(t[1]):0}}class c{#A;constructor(t){this.#A=t}asPlainText(){const t=(new window.DOMParser).parseFromString(this.#A,"text/html");return this.#S(t.body).trim()}#S(t){return t.nodeType===Node.TEXT_NODE?t.nodeValue:Array.from(t.childNodes).map(((e,n)=>{const s=this.#k(e),i=n>0&&this.#k(t.childNodes[n-1]);return(s&&i?"\n\n":"")+this.#S(e)})).join("")}#k(t){return["BR","DIV","H1","H2","H3","H4","H5","H6","LI","P","TR"].includes(t.tagName)}}class h{constructor(t){this.document=t}handleInput(t,e){t.preventDefault();const n=this[`${t.inputType}Handler`];if(n){const i=t.getTargetRanges()[0],{start:r,end:o}=s.fromDOMRange(i,e);n.call(this,t,r,o)}else console.error("Not handling:",t.inputType)}insertTextHandler(t,e,n){this.#C(t,e,n)}insertCompositionTextHandler(t,e,n){this.#C(t,e,n)}insertParagraphHandler(t,e,n){this.document.insertParagraph(e,n)}insertLineBreakHandler(t,e,n){this.document.insertParagraph(e,n)}insertLinkHandler(t,e,n){this.#C(t,e,n)}insertFromYankHandler(t,e,n){this.#C(t,e,n)}insertFromDropHandler(t,e,n){this.#C(t,e,n)}insertFromPasteAsQuotationHandler(t,e,n){const s=t.dataTransfer.getData("text/plain");this.document.replaceText(`> ${s}`,e,n)}insertTransposeHandler(t,e,n){this.#C(t,e,n)}insertReplacementTextHandler(t,e,n){this.#C(t,e,n)}insertFromPasteHandler(t,e,n){this.#C(t,e,n)}deleteContentBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByCutHandler(t,e,n){this.document.deleteText(e,n)}deleteWordBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteWordForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteEntireSoftLineHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByDragHandler(t,e,n){this.document.deleteText(e,n)}deleteContentHandler(t,e,n){this.document.deleteText(e,n)}deleteContentForwardHandler(t,e,n){this.document.deleteText(e,n)}#C(t,e,n){let s;if(t.data)s=t.data;else{const e=t.dataTransfer.getData("text/html");s=e?new c(e).asPlainText():t.dataTransfer.getData("text/plain")}this.document.replaceText(s,e,n)}}class d{constructor(t,e){this.element=t.closest("house-md"),this.file=e}upload(){if(e(this.element,"house-md:before-upload",{file:this.file})){const t=document.createElement("house-md-upload");t.file=this.file,t.uploadsURL=this.#$,this.element.appendChild(t)}}get#$(){return this.element.dataset.uploadsUrl||document.head.querySelector("meta[name=house-uploads-url]")?.content||"/uploads"}}class u{constructor(t){this.document=t}handleAction(t,e){const n=this[t];n?n.call(this,e):console.error("Not handling:",t)}bold(){this.document.toggleBold()}italic(){this.document.toggleItalic()}strikethrough(){this.document.toggleStrikethrough()}quote(){this.document.toggleQuote()}code(){this.document.toggleCode()}link(){this.document.toggleLink()}bulletList(){this.document.toggleBulletList()}numberList(){this.document.toggleNumberList()}undo(){this.document.undo()}redo(){this.document.redo()}uploadFile({file:t}){new d(this.document.element,t).upload()}}class m{constructor(t){this.element=t.closest("house-md")}handleDragOver(t){t.preventDefault()}async handleDrop(t){t.preventDefault();for(const e of t.dataTransfer.items)"file"===e.kind&&new d(this.element,e.getAsFile()).upload()}}const p={"Ctrl+B":"bold","Ctrl+I":"italic","Ctrl+S":"strikethrough","Ctrl+Z":"undo","Ctrl+Shift+Z":"redo"};class g extends HTMLElement{static formAssociated=!0;static observedAttributes=["initialized"];constructor(){super(),this.internals=this.attachInternals(),this.internals.ariaRole="textbox"}attributeChangedCallback(t,e,n){"initialized"===t&&this.isConnected&&null!=e&&e!==n&&requestAnimationFrame((()=>this.#H()))}connectedCallback(){const t=this.querySelector(".house-md-content")?.textContent??this.textContent;this.document=new a(t,this),this.#y(),this.#N(),this.inputHandler=new h(this.document),this.actionHandler=new u(this.document),this.dropAndDropHandler=new m(this),this.autofocus&&this.focus(),this.setAttribute("initialized",""),this.value=t}disconnectedCallback(){this.#_()}validate=t=>{this.required&&this.document.isEmpty?(t.preventDefault(),this.internals.setValidity({valueMissing:!0},"This field is required.",this.element),this.focus()):this.internals.setValidity({})};render(){this.document.render()}focus(){this.contentWrapper.focus()}get required(){return this.hasAttribute("required")}set required(t){t?this.setAttribute("required",""):this.removeAttribute("required")}get autofocus(){return this.hasAttribute("autofocus")}set autofocus(t){t?this.setAttribute("autofocus",""):this.removeAttribute("autofocus")}get value(){return this.document.content}set value(t){this.document.content=t,this.internals.setFormValue(t),this.internals.setValidity({}),this.render()}get form(){return this.internals.form}#y(){this.textContent="",this.toolbar=this.#D(),this.contentWrapper=document.createElement("div"),this.contentWrapper.classList.add("house-md-content"),this.contentWrapper.setAttribute("contenteditable",!0),this.contentWrapper.setAttribute("role","textbox"),this.contentWrapper.setAttribute("aria-multiline",!0),this.append(this.contentWrapper),this.getAttribute("tabindex")?(this.contentWrapper.setAttribute("tabindex",this.getAttribute("tabindex")),this.removeAttribute("tabindex")):this.contentWrapper.setAttribute("tabindex",0)}#D(){if(this.getAttribute("toolbar"))return document.getElementById(this.getAttribute("toolbar"));{const t=document.createElement("house-md-toolbar");return this.prepend(t),t}}#H(){this.disconnectedCallback(),this.getAttribute("toolbar")||this.toolbar.remove(),this.contentWrapper.remove(),this.connectedCallback()}#N(){this.addEventListener("beforeinput",this.#I),this.addEventListener("keydown",this.#B),this.addEventListener("dragover",this.#F),this.addEventListener("drop",this.#R),this.toolbar.addEventListener("house-md:toolbar-action",this.#M),document.addEventListener("selectionchange",this.#z),this.internals.form.addEventListener("submit",this.validate)}#_(){this.removeEventListener("beforeinput",this.#I),this.removeEventListener("keydown",this.#B),this.removeEventListener("dragover",this.#F),this.removeEventListener("drop",this.#R),this.toolbar.removeEventListener("house-md:toolbar-action",this.#M),document.removeEventListener("selectionchange",this.#z),this.internals.form.removeEventListener("submit",this.validate)}#I=t=>{this.inputHandler.handleInput(t,this.contentWrapper)};#B(t){const e=function(t){return p[function(t){const e=[];return(t.metaKey||t.ctrlKey)&&e.push("Ctrl"),t.altKey&&e.push("Alt"),t.shiftKey&&e.push("Shift"),e.push(t.key.toUpperCase()),e.join("+")}(t)]}(t);e&&(t.preventDefault(),this.actionHandler.handleAction(e))}#F=t=>{this.dropAndDropHandler.handleDragOver(t)};#R=t=>{this.dropAndDropHandler.handleDrop(t)};#M=({detail:t})=>{this.actionHandler.handleAction(t.houseMdAction,t)};#z=()=>{this.document.selection.update()}}customElements.define("house-md",g);class v extends HTMLElement{#W;#$;#O;constructor(){super(),this.percentComplete=0,this.status="pending"}connectedCallback(){this.#t(),this.#q(),this.addEventListener("click",this.#e)}disconnectedCallback(){this.removeEventListener("click",this.#e)}async#q(){const t=new FormData;t.append("file",this.file),this.csrfParamName&&t.append(this.csrfParamName,this.csrfToken),this.xhr=new XMLHttpRequest,this.xhr.open("POST",this.uploadsURL,!0),this.xhr.upload.onprogress=this.#P,this.xhr.onload=this.#U,this.xhr.onerror=this.#K,this.xhr.onabort=this.#Q,this.xhr.send(t),this.status="uploading",this.#t()}#t(){this.setAttribute("status",this.status),this.innerHTML=`\n ${this.#V()}\n ${this.#X()}\n ${this.#j()}\n ${this.#Z()}\n `}#V(){return"failed"===this.status?"":""}#X(){return`${this.file.name} `}#j(){return``}#Z(){return this.#O?`${this.#O} `:""}#P=t=>{t.lengthComputable&&(this.percentComplete=t.loaded/t.total*100),this.#t()};#U=async()=>{if(this.xhr.status>=400)this.status="failed",this.#O=this.xhr.responseText||"Upload failed";else{const t=JSON.parse(this.xhr.responseText);await this.document.insertFile(t.fileName,t.fileUrl,t.mimetype),this.status="complete",this.#J()}this.#t()};#K=()=>{this.status="failed",this.#t()};#Q=()=>{this.status="aborted",this.#t()};#e=t=>{t.target.matches("[data-md-action=close]")&&this.remove()};async#J(t=500){await function(t){return new Promise((e=>setTimeout(e,t)))}(t),this.remove()}get document(){return this.closest("house-md").document}get file(){return this.#W}set file(t){this.#W=t}get uploadsURL(){return this.#$}set uploadsURL(t){this.#$=t}get csrfParamName(){return document.head.querySelector("meta[name=csrf-param]")?.content}get csrfToken(){return document.head.querySelector("meta[name=csrf-token]")?.content}}customElements.define("house-md-upload",v);export{a as Document,g as Editor,o as History,r as Selection,n as Toolbar,v as Upload};
<%= 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" } %>
-- <%= 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 %>
-~~$1~~")}(t),t=function(t){return t.replaceAll(/==(.*?)==/gm,"==$1==")}(t),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/^# (.*)$/gm,'# $1'),t=t.replaceAll(/^## (.*)$/gm,'## $1'),t=t.replaceAll(/^### (.*)$/gm,'### $1'),t=t.replaceAll(/^#### (.*)$/gm,'#### $1'),t=t.replaceAll(/^##### (.*)$/gm,'##### $1'),t=t.replaceAll(/^###### (.*)$/gm,'###### $1'),t}(t),t=function(t){return t.replaceAll(/^(\d+\.\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^(-\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^>(.*)$/gm,'>$1')}(t),t}(e),e=function(t){return function(t){return t.replaceAll(/\n---\n/gm,'\n---\n')}(t)}(e),e=function(t){return t.replaceAll(/(?[$1]($2)')}(e),e=function(t){return t.replaceAll(/!\[(.*?)\]\((.*?)\)/gm,'$&')}(e),e=function(t){return t=t.replaceAll(/^```(.*?)```$/gms,'```$1```'),t=t.replaceAll(/([^`])`([^`]+)`([^`])/gm,'$1`$2`$3'),t}(e),e=function(t){return t.replaceAll(/<!--(.*?)-->/gm,'<!--$1-->')}(e),e}class a{constructor(t,e){this.content=t,this.element=e,this.selection=new r(this),this.history=new o}select(t,e){this.selection.select({start:t,end:e})}insertText(t){this.replaceText(t,this.currentSelection.start,this.currentSelection.end)}replaceText(t,e,n){const s=this.content.slice(0,e),i=`${s}${t}${this.content.slice(n)}`,r={start:s.length+t.length,end:s.length+t.length};this.#h(),this.#d(i,r)}insertParagraph(t,e){this.select(t,e),this.selection.isBulletList?this.#u():this.selection.isNumberList?this.#m():this.replaceText("\n",t,e)}insertLink(t,e,n=""){const{start:s,end:i}=this.currentSelection,r=` ${n}[${t}](${e}) `;this.replaceText(r,s,i),this.selection.select({start:s+r.length,end:s+r.length})}insertFile(t,e,n){n.startsWith("image/")?this.insertImage(t,e):this.insertLink(t,e)}insertImage(t,e){this.insertLink(t,e,"!")}deleteText(t,e){this.replaceText("",Math.max(t,0),e)}deleteCurrentLine(){const{start:t,end:e}=this.currentLine;this.deleteText(t,e)}toggleBold(){this.#p(this.selection.isBold,"**")}toggleItalic(){this.#p(this.selection.isItalic,"_")}toggleStrikethrough(){this.#p(this.selection.isStrikethrough,"~~")}toggleCode(){const[t,e]=this.selection.isMultiline?["```\n","\n```"]:["`","`"];this.#p(this.selection.isCode,t,e)}toggleLink(){this.selection.isLink?this.#g():this.#v()}toggleQuote(){this.selection.isQuote?this.#f("> "):this.#b("> ")}toggleBulletList(){this.selection.isBulletList?this.#f("- "):this.#b("- ")}toggleNumberList(){this.selection.isNumberList?this.#f(/\d+\. /):this.#b(`${this.#L+1}. `)}undo(){const{content:e,start:n,end:s}=this.history.undo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}redo(){const{content:e,start:n,end:s}=this.history.redo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}render(){this.contentElement.innerHTML=l(this.content)+"