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 = `
${savedContent}
` // 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: '', diff --git a/app/javascript/controllers/paste_controller.js b/app/javascript/controllers/paste_controller.js deleted file mode 100644 index 79db7d7db..000000000 --- a/app/javascript/controllers/paste_controller.js +++ /dev/null @@ -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) - } - } - } -} \ No newline at end of file diff --git a/app/javascript/controllers/syntax_highlight_controller.js b/app/javascript/controllers/syntax_highlight_controller.js new file mode 100644 index 000000000..02b66a0f7 --- /dev/null +++ b/app/javascript/controllers/syntax_highlight_controller.js @@ -0,0 +1,8 @@ +import { Controller } from "@hotwired/stimulus" +import { highlightAll } from "actiontext-lexical" + +export default class extends Controller { + connect() { + highlightAll() + } +} diff --git a/app/models/card.rb b/app/models/card.rb index 8707b2bf2..69588ea88 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -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? diff --git a/app/models/command/go_to_card.rb b/app/models/command/go_to_card.rb index 0943413f3..a330d733b 100644 --- a/app/models/command/go_to_card.rb +++ b/app/models/command/go_to_card.rb @@ -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 diff --git a/app/models/comment.rb b/app/models/comment.rb index ebb1229a4..f62f457a0 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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 diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index 08e0362cb..e8efb550c 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -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 diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb new file mode 100644 index 000000000..6a35805e0 --- /dev/null +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -0,0 +1,42 @@ +<% if blob.representable? %> +
+ <%= render "active_storage/blobs/web/representation", blob: blob %> + +
+ <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <%= blob.filename %> + <% end %> + · + <%= number_to_human_size blob.byte_size %> + · + <%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %> + Download + <% end %> +
+
+<% else %> +
+ <%= 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 %> + +
+
+ <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <%= blob.filename %> + <% end %> +
+
+ <%= number_to_human_size blob.byte_size %> + · + <%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %> + Download + <% end %> +
+
+
+<% end %> diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb new file mode 100644 index 000000000..cd7400668 --- /dev/null +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -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? %> + +<% 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 %> + <%= blob.filename.extension&.downcase.presence || "unknown" %> +<% end %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 800c44bcd..a1e863bf9 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -28,8 +28,8 @@ <% end %> -
- <%= sanitize comment.body_html %> +
+ <%= comment.body %>
<%= render "cards/comments/reactions/reactions", comment: comment %> diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index c4645fe86..68659dd38 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -4,13 +4,13 @@
-
+
<%= 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 %> Post this comment <% end %> diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index 342afc590..726693c47 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -5,10 +5,10 @@
-
+
<%= 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) %>
<%= form.button class: "btn btn--reversed", type: :submit do %> Save changes diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 4e4c2c9f5..1767fd854 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -4,23 +4,23 @@

<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>

-
- <%= sanitize card.description_html %> +
+ <%= card.description %>
<% 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| %> -

+ <%= 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.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.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.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 %>

-
- <%= 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 - - #{alt_text} - - 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,"~~$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)+"",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`${this.percentComplete}%`}#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};