From 08d8b2e5ff9c7ebb8d9fbbe2b97c8ef3697f7add Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 29 May 2025 14:22:27 +0200 Subject: [PATCH] Revert "Replace House with Lexical" --- .DS_Store | Bin 6148 -> 0 bytes Dockerfile | 2 +- Gemfile | 2 - Gemfile.lock | 22 -- app/assets/stylesheets/_global.css | 11 - app/assets/stylesheets/actiontext-lexical.css | 113 ------ app/assets/stylesheets/cards.css | 7 - app/assets/stylesheets/comments.css | 23 +- app/assets/stylesheets/markdown-content.css | 110 ++++++ app/assets/stylesheets/rich-text-content.css | 331 ------------------ app/helpers/excerpt_helper.rb | 12 - app/helpers/notifications_helper.rb | 2 +- app/javascript/application.js | 4 +- .../controllers/local_save_controller.js | 7 +- .../controllers/paste_controller.js | 28 ++ .../syntax_highlight_controller.js | 8 - app/models/card.rb | 2 +- app/models/comment.rb | 6 +- app/models/concerns/mentions.rb | 8 +- app/views/active_storage/blobs/_blob.html.erb | 42 --- .../blobs/web/_representation.html.erb | 19 - app/views/cards/comments/_comment.html.erb | 4 +- app/views/cards/comments/_new.html.erb | 10 +- app/views/cards/comments/edit.html.erb | 6 +- app/views/cards/container/_title.html.erb | 12 +- app/views/cards/edit.html.erb | 10 +- app/views/events/event/_comment.html.erb | 2 +- .../action_text/contents/_content.html.erb | 3 - app/views/layouts/application.html.erb | 2 +- config/importmap.rb | 4 +- config/initializers/markdown.rb | 7 + ...4_create_action_text_tables.action_text.rb | 26 -- db/schema.rb | 12 +- db/schema_cache.yml | 196 +++++------ lib/markdown_renderer.rb | 81 +++++ lib/rails_ext/action_text_has_markdown.rb | 47 +++ lib/rails_ext/action_text_markdown.rb | 44 +++ lib/rails_ext/action_text_tag_helper.rb | 36 ++ lib/rails_ext/active_storage_sluggable.rb | 32 ++ script/convert_markdowns_to_rich_text.rb | 104 ------ script/migrate-to-multi-tenant | 20 +- script/reset_cards_ids.rb | 6 +- script/reset_collections_ids.rb | 6 +- .../cards/comments_controller_test.rb | 4 +- test/controllers/cards_controller_test.rb | 2 +- test/controllers/uploads_controller_test.rb | 25 ++ .../{rich_texts.yml => markdowns.yml} | 8 +- test/test_helper.rb | 2 +- test/test_helpers/action_text_test_helper.rb | 21 -- vendor/javascript/house.min.js | 1 + 50 files changed, 576 insertions(+), 916 deletions(-) delete mode 100644 .DS_Store delete mode 100644 app/assets/stylesheets/actiontext-lexical.css create mode 100644 app/assets/stylesheets/markdown-content.css delete mode 100644 app/assets/stylesheets/rich-text-content.css delete mode 100644 app/helpers/excerpt_helper.rb create mode 100644 app/javascript/controllers/paste_controller.js delete mode 100644 app/javascript/controllers/syntax_highlight_controller.js delete mode 100644 app/views/active_storage/blobs/_blob.html.erb delete mode 100644 app/views/active_storage/blobs/web/_representation.html.erb delete mode 100644 app/views/layouts/action_text/contents/_content.html.erb create mode 100644 config/initializers/markdown.rb delete mode 100644 db/migrate/20250523084734_create_action_text_tables.action_text.rb create mode 100644 lib/markdown_renderer.rb create mode 100644 lib/rails_ext/action_text_has_markdown.rb create mode 100644 lib/rails_ext/action_text_markdown.rb create mode 100644 lib/rails_ext/action_text_tag_helper.rb create mode 100644 lib/rails_ext/active_storage_sluggable.rb delete mode 100755 script/convert_markdowns_to_rich_text.rb create mode 100644 test/controllers/uploads_controller_test.rb rename test/fixtures/action_text/{rich_texts.yml => markdowns.yml} (69%) delete mode 100644 test/test_helpers/action_text_test_helper.rb create mode 100644 vendor/javascript/house.min.js diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 1.14" gem "platform_agent" # Telemetry and logging diff --git a/Gemfile.lock b/Gemfile.lock index 0ed3a138e..5b9940aad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,3 @@ -GIT - remote: https://github.com/basecamp/actiontext-lexical - revision: 777b2a4a933db9cb4cd515c4569dfcaf8cae45b5 - specs: - actiontext-lexical (0.1.0) - rails (>= 8.0.2) - GIT remote: https://github.com/basecamp/active_record-tenanted revision: 523947a0f0f49f66a8a62eb93e9ba1a345b3076b @@ -195,10 +188,6 @@ 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) @@ -210,9 +199,6 @@ 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) @@ -250,9 +236,6 @@ 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) @@ -371,9 +354,6 @@ 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) @@ -468,7 +448,6 @@ PLATFORMS x86_64-mingw32 DEPENDENCIES - actiontext-lexical! active_record-tenanted! bcrypt (~> 3.1.7) bootsnap @@ -477,7 +456,6 @@ 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 5b2a966af..aad4508fc 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -177,17 +177,6 @@ --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 deleted file mode 100644 index c0ab455a5..000000000 --- a/app/assets/stylesheets/actiontext-lexical.css +++ /dev/null @@ -1,113 +0,0 @@ -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/cards.css b/app/assets/stylesheets/cards.css index d13b60498..4e3c237f7 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -150,13 +150,6 @@ } } - .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%; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 05d3518bc..2a5e47960 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-half); + --comment-padding-block: var(--block-space); --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.75) calc(var(--comment-padding-inline) * -0.75); + margin: calc(var(--comment-padding-block) * 0.5) 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,8 +83,6 @@ 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; } @@ -115,7 +113,7 @@ line-height: 1.2; max-inline-size: var(--comment-max); text-align: center; - + &::before { /* Make up space for lack of avatar */ @@ -135,4 +133,17 @@ text-align: center !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 new file mode 100644 index 000000000..81589501b --- /dev/null +++ b/app/assets/stylesheets/markdown-content.css @@ -0,0 +1,110 @@ +@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 deleted file mode 100644 index 52254f84d..000000000 --- a/app/assets/stylesheets/rich-text-content.css +++ /dev/null @@ -1,331 +0,0 @@ -@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/helpers/excerpt_helper.rb b/app/helpers/excerpt_helper.rb deleted file mode 100644 index 9ae0d3274..000000000 --- a/app/helpers/excerpt_helper.rb +++ /dev/null @@ -1,12 +0,0 @@ -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 8dab7c110..57fd63eff 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.to_s).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body.to_s).truncate(200)}" + "#{strip_tags(comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body_html).truncate(200)}" end def card_notification_title(card) diff --git a/app/javascript/application.js b/app/javascript/application.js index 36d861cc7..c2917aa3e 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,6 +2,4 @@ import "@hotwired/turbo-rails" import "initializers" import "controllers" - -import "actiontext-lexical" -import "@rails/actiontext" +import "house" diff --git a/app/javascript/controllers/local_save_controller.js b/app/javascript/controllers/local_save_controller.js index 5f9fc83a3..5d9b89a72 100644 --- a/app/javascript/controllers/local_save_controller.js +++ b/app/javascript/controllers/local_save_controller.js @@ -30,10 +30,9 @@ export default class extends Controller { async restoreContent() { await nextFrame() - let savedContent = localStorage.getItem(this.keyValue) + const savedContent = localStorage.getItem(this.keyValue) if (savedContent) { - savedContent = `
${savedContent}
` // temporary for old markdown saves this.inputTarget.value = savedContent this.#triggerChangeEvent(savedContent) } @@ -46,8 +45,8 @@ export default class extends Controller { } #triggerChangeEvent(newValue) { - if (this.inputTarget.tagName === "LEXICAL-EDITOR") { - this.inputTarget.dispatchEvent(new CustomEvent('actiontext:change', { + if (this.inputTarget.tagName === "HOUSE-MD") { + this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', { bubbles: true, detail: { previousContent: '', diff --git a/app/javascript/controllers/paste_controller.js b/app/javascript/controllers/paste_controller.js new file mode 100644 index 000000000..79db7d7db --- /dev/null +++ b/app/javascript/controllers/paste_controller.js @@ -0,0 +1,28 @@ +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 deleted file mode 100644 index 02b66a0f7..000000000 --- a/app/javascript/controllers/syntax_highlight_controller.js +++ /dev/null @@ -1,8 +0,0 @@ -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 69588ea88..8707b2bf2 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_rich_text :description + has_markdown :description before_save :set_default_title, if: :published? diff --git a/app/models/comment.rb b/app/models/comment.rb index f62f457a0..ebb1229a4 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_rich_text :body + has_markdown :body searchable_by :body_plain_text, using: :comments_search_index, as: :body scope :chronologically, -> { order created_at: :asc, id: :desc } @@ -19,10 +19,6 @@ 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 e8efb550c..08e0362cb 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -14,7 +14,7 @@ module Mentions end def mentionable_content - rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ") + markdown_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ") end private @@ -32,12 +32,12 @@ module Mentions collection.users end - def rich_text_associations - self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText } + def markdown_associations + self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::Markdown } end def mentionable_content_changed? - rich_text_associations.any? { send(it.name).body_previously_changed? } + markdown_associations.any? { send(it.name).content_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 deleted file mode 100644 index 6a35805e0..000000000 --- a/app/views/active_storage/blobs/_blob.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -<% 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 deleted file mode 100644 index cd7400668..000000000 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% 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 a1e863bf9..800c44bcd 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -28,8 +28,8 @@ <% end %> -
- <%= comment.body %> +
+ <%= sanitize comment.body_html %>
<%= 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 68659dd38..c4645fe86 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 local-save", + data: { controller: "form paste local-save", local_save_key_value: "comment-#{card.id}", - action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> - <%= 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" } %> + 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" } %> <%= form.button class: "comment__submit btn btn--reversed flex-item-justify-start" do %> Post this comment <% end %> diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index 726693c47..342afc590 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", 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) %> + 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) %>
<%= 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 8bbe68550..4e4c2c9f5 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" %>

-
- <%= card.description %> +
+ <%= sanitize card.description_html %>
<% end %> <% else %> - <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "auto-save" } do |form| %> + <%= 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.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" } %>

-
- <%= form.rich_textarea :description, class: "card-field__description", +
+ <%= 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: "actiontext:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %> + data: { action: "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" } %>
<% end %> <% end %> diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb index c02e2a635..499563bb4 100644 --- a/app/views/cards/edit.html.erb +++ b/app/views/cards/edit.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag @card, :edit do %> - <%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "form" } do |form| %> + <%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "form paste" } 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", @@ -8,10 +8,10 @@ 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" } %> <% end %>

- - <%= 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.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.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 ed0cbb79c..22f4596e8 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 %> - <%= format_excerpt(event&.eventable.body, length: 200) -%> + <%= strip_tags(event&.eventable.body_html).truncate(200) -%> <% end %> diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb deleted file mode 100644 index 0dec72775..000000000 --- a/app/views/layouts/action_text/contents/_content.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= yield -%> -
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b667bfb47..0e04cbca9 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 2abe3a805..65dc7629c 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,11 +5,9 @@ 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 new file mode 100644 index 000000000..04d5c8e78 --- /dev/null +++ b/config/initializers/markdown.rb @@ -0,0 +1,7 @@ +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 deleted file mode 100644 index c1c0a9d8c..000000000 --- a/db/migrate/20250523084734_create_action_text_tables.action_text.rb +++ /dev/null @@ -1,26 +0,0 @@ -# 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 098674f3a..834b2bcc9 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_23_084734) do +ActiveRecord::Schema[8.1].define(version: 2025_05_15_125505) do create_table "accesses", force: :cascade do |t| t.integer "collection_id", null: false t.datetime "created_at", null: false @@ -39,16 +39,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) 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 6cd19a67c..fbaacdc9c 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1,7 +1,7 @@ --- !ruby/object:ActiveRecord::ConnectionAdapters::SchemaCache columns: accesses: - - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &22 !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: - - &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &24 !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: &11 !ruby/object:ActiveRecord::Type::Text + cast_type: &14 !ruby/object:ActiveRecord::Type::Text true: t false: f precision: scale: limit: - sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: TEXT type: :text limit: @@ -159,30 +159,17 @@ columns: collation: comment: - *9 - 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 + active_storage_attachments: - &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: record_id - cast_type: &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer + name: blob_id + cast_type: &11 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer precision: scale: limit: max: 9223372036854775808 min: -9223372036854775808 - sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: bigint type: :integer limit: @@ -193,23 +180,19 @@ columns: default_function: collation: comment: - - *13 - - *9 - active_storage_attachments: - - &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *5 + - *6 + - *10 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: blob_id - cast_type: *14 - sql_type_metadata: *15 + name: record_id + cast_type: *11 + sql_type_metadata: *12 'null': false default: default_function: collation: comment: - - *5 - - *6 - - *10 - - *16 - *13 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -225,8 +208,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: byte_size - cast_type: *14 - sql_type_metadata: *15 + cast_type: *11 + sql_type_metadata: *12 'null': false default: default_function: @@ -264,7 +247,7 @@ columns: collation: comment: - *6 - - &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: key cast_type: *7 @@ -277,8 +260,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: metadata - cast_type: *11 - sql_type_metadata: *12 + cast_type: *14 + sql_type_metadata: *15 'null': true default: default_function: @@ -295,7 +278,7 @@ columns: collation: comment: active_storage_variant_records: - - *17 + - *16 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -309,7 +292,7 @@ columns: comment: ar_internal_metadata: - *5 - - *18 + - *17 - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -322,7 +305,7 @@ columns: collation: comment: assignees_filters: - - &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: assignee_id cast_type: *1 @@ -332,7 +315,7 @@ columns: default_function: collation: comment: - - &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: filter_id cast_type: *1 @@ -343,7 +326,7 @@ columns: collation: comment: assigners_filters: - - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: assigner_id cast_type: *1 @@ -353,11 +336,11 @@ columns: default_function: collation: comment: - - *19 + - *18 assignments: + - *19 - *20 - - *21 - - &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: card_id cast_type: *1 @@ -385,14 +368,14 @@ columns: - *6 - *9 card_goldnesses: - - *22 + - *21 - *5 - *6 - *9 cards: - - *23 + - *22 - *5 - - &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: creator_id cast_type: *1 @@ -445,14 +428,14 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: status - cast_type: *11 - sql_type_metadata: *12 + cast_type: *14 + sql_type_metadata: *15 'null': false default: creating default_function: collation: comment: - - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title cast_type: *7 @@ -478,7 +461,7 @@ columns: comment: - *9 closures: - - *22 + - *21 - *5 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -506,11 +489,11 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &32 !ruby/object:ActiveModel::Type::Boolean + cast_type: &31 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -524,15 +507,15 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: auto_close_period - cast_type: *14 - sql_type_metadata: *15 + cast_type: *11 + sql_type_metadata: *12 'null': true default: default_function: collation: comment: - *5 - - *24 + - *23 - *6 - *10 - *9 @@ -547,18 +530,18 @@ columns: collation: comment: collections_filters: - - *23 - - *19 + - *22 + - *18 commands: - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: data - cast_type: &26 !ruby/object:ActiveRecord::Type::Json + cast_type: &25 !ruby/object:ActiveRecord::Type::Json precision: scale: limit: - sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &26 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: json type: :json limit: @@ -573,8 +556,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: line - cast_type: *11 - sql_type_metadata: *12 + cast_type: *14 + sql_type_metadata: *15 'null': true default: default_function: @@ -601,16 +584,16 @@ columns: collation: comment: - *9 - - *25 - comments: - - *22 - - *5 - *24 + comments: + - *21 + - *5 + - *23 - *6 - *9 creators_filters: - - *24 - - *19 + - *23 + - *18 events: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -622,9 +605,9 @@ columns: default_function: collation: comment: - - *23 + - *22 - *5 - - *24 + - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: eventable_id @@ -649,8 +632,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: particulars - cast_type: *26 - sql_type_metadata: *27 + cast_type: *25 + sql_type_metadata: *26 'null': true default: "{}" default_function: @@ -659,12 +642,12 @@ columns: - *9 filters: - *5 - - *24 + - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: fields - cast_type: *26 - sql_type_metadata: *27 + cast_type: *25 + sql_type_metadata: *26 'null': false default: "{}" default_function: @@ -683,7 +666,7 @@ columns: comment: - *9 filters_stages: - - *19 + - *18 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: stage_id @@ -695,8 +678,8 @@ columns: collation: comment: filters_tags: - - *19 - - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *18 + - &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *1 @@ -729,7 +712,7 @@ columns: default_function: collation: comment: - - &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &27 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: source_id cast_type: *1 @@ -739,7 +722,7 @@ columns: default_function: collation: comment: - - &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: source_type cast_type: *7 @@ -773,16 +756,16 @@ columns: default_function: collation: comment: + - *27 - *28 - - *29 - *9 - - *25 + - *24 pins: - - *22 + - *21 - *5 - *6 - *9 - - *25 + - *24 reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -899,24 +882,24 @@ columns: default_function: collation: comment: - - *25 + - *24 taggings: - - *22 + - *21 - *5 - *6 - - *30 + - *29 - *9 tags: - *5 - *6 - - *31 + - *30 - *9 users: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *32 - sql_type_metadata: *33 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -957,16 +940,16 @@ columns: comment: - *9 watches: - - *22 + - *21 - *5 - *6 - *9 - - *25 + - *24 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *32 - sql_type_metadata: *33 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -1006,7 +989,6 @@ 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 @@ -1045,7 +1027,6 @@ 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 @@ -1150,25 +1131,6 @@ 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 @@ -2199,4 +2161,4 @@ indexes: comment: valid: true workflows: [] -version: 20250523084734 +version: 20250515125505 diff --git a/lib/markdown_renderer.rb b/lib/markdown_renderer.rb new file mode 100644 index 000000000..e90e86fb4 --- /dev/null +++ b/lib/markdown_renderer.rb @@ -0,0 +1,81 @@ +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 new file mode 100644 index 000000000..b792657d5 --- /dev/null +++ b/lib/rails_ext/action_text_has_markdown.rb @@ -0,0 +1,47 @@ +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 new file mode 100644 index 000000000..cd19fa001 --- /dev/null +++ b/lib/rails_ext/action_text_markdown.rb @@ -0,0 +1,44 @@ +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 new file mode 100644 index 000000000..d662da7b2 --- /dev/null +++ b/lib/rails_ext/action_text_tag_helper.rb @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..d4d4be335 --- /dev/null +++ b/lib/rails_ext/active_storage_sluggable.rb @@ -0,0 +1,32 @@ +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 deleted file mode 100755 index 1a43db239..000000000 --- a/script/convert_markdowns_to_rich_text.rb +++ /dev/null @@ -1,104 +0,0 @@ -#!/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 - -ApplicationRecord.with_each_tenant do |tenant| - puts "Processing tenant: #{tenant}" - - ActionText::Markdown.find_each do |markdown| - 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 diff --git a/script/migrate-to-multi-tenant b/script/migrate-to-multi-tenant index 79db5c0e0..8d035f37e 100755 --- a/script/migrate-to-multi-tenant +++ b/script/migrate-to-multi-tenant @@ -214,6 +214,9 @@ 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 @@ -225,7 +228,22 @@ class EnactTenanting end def update_action_text_urls - raise "No more markdown" + 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 end def hardlink_active_storage diff --git a/script/reset_cards_ids.rb b/script/reset_cards_ids.rb index 65c556283..19305b5f2 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::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]) + 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]) end end diff --git a/script/reset_collections_ids.rb b/script/reset_collections_ids.rb index cf1d84028..142e9e2a9 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::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]) + 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]) end end diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index 76ff0f6fa..690cf0656 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_action_text "I've changed my mind", comments(:logo_agreement_kevin).reload.body + assert_equal "I've changed my mind", comments(:logo_agreement_kevin).reload.body.content end test "update another user's comment" do - assert_no_changes -> { comments(:logo_agreement_jz).reload.body.to_s } do + assert_no_changes "comments(:logo_agreement_jz).body.content" 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 75ba45147..5cb3a6582 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.to_plain_text.strip + assert_equal "Something more in-depth", card.description_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 new file mode 100644 index 000000000..3f5f54af5 --- /dev/null +++ b/test/controllers/uploads_controller_test.rb @@ -0,0 +1,25 @@ +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/rich_texts.yml b/test/fixtures/action_text/markdowns.yml similarity index 69% rename from test/fixtures/action_text/rich_texts.yml rename to test/fixtures/action_text/markdowns.yml index 9a86c3b30..519cd408b 100644 --- a/test/fixtures/action_text/rich_texts.yml +++ b/test/fixtures/action_text/markdowns.yml @@ -1,16 +1,14 @@ logo_agreement_jz: record: logo_agreement_jz (Comment) name: body - body: I agree. + content: I agree. logo_agreement_kevin: record: logo_agreement_kevin (Comment) name: body - body: Same, let’s do it. + content: Same, let’s do it. layout_overflowing_david: record: layout_overflowing_david (Comment) name: body - body: The text is overflowing the container. - - + content: The text is overflowing the container. diff --git a/test/test_helper.rb b/test/test_helper.rb index 4e0a9fcee..6e27d82fa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,7 +22,7 @@ module ActiveSupport fixtures :all include ActiveJob::TestHelper - include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper + include 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 deleted file mode 100644 index 09a26fceb..000000000 --- a/test/test_helpers/action_text_test_helper.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 new file mode 100644 index 000000000..c81e324e5 --- /dev/null +++ b/vendor/javascript/house.min.js @@ -0,0 +1 @@ +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};