collavre 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,320 @@
1
+ import {
2
+ $isTextNode,
3
+ $isParagraphNode,
4
+ $isLineBreakNode,
5
+ $createParagraphNode
6
+ } from "lexical"
7
+ import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"
8
+ import { TABLE, setCellTransformers } from "./table_transformer"
9
+
10
+ // Serializes the Lexical editor state to Markdown as the canonical storage
11
+ // format. Standard block/inline features (headings, lists, quotes, code,
12
+ // bold/italic/strikethrough, inline code, links) round-trip through Markdown
13
+ // via the upstream TRANSFORMERS. Features Markdown can't express are emitted as
14
+ // a SMALL, FIXED set of normalized inline HTML fragments:
15
+ //
16
+ // - text color / background-color -> <span style="...">
17
+ // - images / videos / attachments -> raw <img>/<video>/<a download> tags
18
+ //
19
+ // The HTML shape is normalized (one canonical form per feature) and the CSS
20
+ // values are validated, so the server-side sanitizer can safelist exactly these
21
+ // and the import path (markdown -> HTML -> Lexical) reconstructs them losslessly.
22
+
23
+ // Only these CSS color values are allowed inside an emitted <span>. Anything
24
+ // else (url(), expression(), javascript:, <, >) is dropped so a crafted inline
25
+ // style can't smuggle script or external requests into stored Markdown.
26
+ const SAFE_COLOR_VALUE =
27
+ /^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9-_]+\)|[a-zA-Z]+)$/
28
+
29
+ function safeColorValue(value) {
30
+ if (!value) return null
31
+ const v = value.trim().replace(/;+$/, "").trim()
32
+ if (!v) return null
33
+ if (/url\(|expression|javascript:|@import|[<>]/i.test(v)) return null
34
+ return SAFE_COLOR_VALUE.test(v) ? v : null
35
+ }
36
+
37
+ function colorBgFromStyle(styleText) {
38
+ let color = null
39
+ let background = null
40
+ ;(styleText || "").split(";").forEach((decl) => {
41
+ const idx = decl.indexOf(":")
42
+ if (idx === -1) return
43
+ const key = decl.slice(0, idx).trim().toLowerCase()
44
+ const value = decl.slice(idx + 1)
45
+ if (key === "color") color = safeColorValue(value)
46
+ else if (key === "background-color") background = safeColorValue(value)
47
+ })
48
+ return { color, background }
49
+ }
50
+
51
+ function escapeHtmlAttr(value) {
52
+ return String(value == null ? "" : value)
53
+ .replace(/&/g, "&amp;")
54
+ .replace(/"/g, "&quot;")
55
+ .replace(/</g, "&lt;")
56
+ .replace(/>/g, "&gt;")
57
+ }
58
+
59
+ function escapeHtmlText(value) {
60
+ return String(value == null ? "" : value)
61
+ .replace(/&/g, "&amp;")
62
+ .replace(/</g, "&lt;")
63
+ .replace(/>/g, "&gt;")
64
+ }
65
+
66
+ // Wrap already-formatted inner Markdown text in a normalized colored <span>, or
67
+ // return null when the style carries no (safe) color/background. The declaration
68
+ // order is fixed (color first) so the same selection always serializes to the
69
+ // same bytes — line-diff/snapshot stability depends on this.
70
+ export function colorSpanMarkup(styleText, inner) {
71
+ const { color, background } = colorBgFromStyle(styleText)
72
+ if (!color && !background) return null
73
+ const decls = []
74
+ if (color) decls.push(`color: ${color}`)
75
+ if (background) decls.push(`background-color: ${background}`)
76
+ // `inner` is the Markdown-formatted text of a colored node. Lexical's export
77
+ // escapes Markdown punctuation but NOT HTML metacharacters, so colored text
78
+ // like `<foo>` would become raw markup inside the <span> and get reinterpreted
79
+ // (and stripped) by the Markdown renderer/sanitizer. Escape <, >, & so user
80
+ // text stays text. Markdown syntax chars (*, _, `, ~) are left untouched.
81
+ return `<span style="${decls.join("; ")}">${escapeHtmlText(inner)}</span>`
82
+ }
83
+
84
+ // Canonical inline HTML for the decorator nodes, matching the shapes the import
85
+ // path (markdown_to_html -> $generateNodesFromDOM) and the server sanitizer
86
+ // expect. Numeric width/height only — Lexical's "inherit" sentinel is dropped.
87
+ export function imageMarkup({ src, altText, width, height }) {
88
+ let html = `<img src="${escapeHtmlAttr(src)}" alt="${escapeHtmlAttr(altText)}"`
89
+ if (Number.isFinite(Number(width)) && Number(width) > 0) {
90
+ html += ` width="${escapeHtmlAttr(width)}"`
91
+ }
92
+ if (Number.isFinite(Number(height)) && Number(height) > 0) {
93
+ html += ` height="${escapeHtmlAttr(height)}"`
94
+ }
95
+ return `${html}>`
96
+ }
97
+
98
+ export function videoMarkup({ src }) {
99
+ return `<video controls src="${escapeHtmlAttr(src)}"></video>`
100
+ }
101
+
102
+ export function attachmentMarkup({ src, filename, filesize }) {
103
+ let html = `<a href="${escapeHtmlAttr(src)}" download="${escapeHtmlAttr(filename)}"`
104
+ if (filesize != null && filesize !== "") {
105
+ html += ` data-filesize="${escapeHtmlAttr(filesize)}"`
106
+ }
107
+ return `${html}>${escapeHtmlText(filename)}</a>`
108
+ }
109
+
110
+ // Boilerplate so a transformer can be export-only: import never fires.
111
+ const NEVER = /(?!)/
112
+
113
+ function exportOnlyTransformer(exportFn, { dependencies = [], type = "text-match" } = {}) {
114
+ return {
115
+ dependencies,
116
+ export: exportFn,
117
+ importRegExp: NEVER,
118
+ regExp: NEVER,
119
+ replace: () => false,
120
+ trigger: "",
121
+ type
122
+ }
123
+ }
124
+
125
+ // Decorator nodes -> raw HTML. Duck-typed via getType() so this module stays
126
+ // free of the .jsx node classes (kept importable under native-ESM Jest).
127
+ function decoratorMarkup(node) {
128
+ const type = node.getType ? node.getType() : null
129
+ if (type === "image") {
130
+ return imageMarkup({
131
+ src: node.getSrc?.(),
132
+ altText: node.getAltText?.(),
133
+ width: node.__width,
134
+ height: node.__height
135
+ })
136
+ }
137
+ if (type === "video") {
138
+ return videoMarkup({ src: node.getSrc?.() })
139
+ }
140
+ if (type === "attachment") {
141
+ return attachmentMarkup({
142
+ src: node.getSrc?.(),
143
+ filename: node.getFilename?.(),
144
+ filesize: node.__filesize
145
+ })
146
+ }
147
+ return null
148
+ }
149
+
150
+ // A paragraph the user left blank: it carries no real text, only empty text
151
+ // nodes and/or line breaks. (Decorator- or text-bearing paragraphs are NOT
152
+ // blank, so media and real content keep their normal export.) An empty
153
+ // paragraph from pressing Enter has zero children; a run of blank lines that
154
+ // was reopened comes back as ONE paragraph holding N LineBreakNodes (the
155
+ // importer groups consecutive <br> markers together).
156
+ function isBlankParagraph(node) {
157
+ if (!$isParagraphNode(node)) return false
158
+ // A blank paragraph inside a table cell is just an empty cell — it must stay
159
+ // empty (`| |`), not become a `<br>`. The cell serializer reuses this
160
+ // transformer set, so exclude paragraphs nested in a cell (duck-typed by type
161
+ // to keep this module free of the @lexical/table import).
162
+ const parent = node.getParent ? node.getParent() : null
163
+ if (parent && parent.getType && parent.getType() === "tablecell") return false
164
+ return node
165
+ .getChildren()
166
+ .every((child) => $isLineBreakNode(child) || ($isTextNode(child) && !child.getTextContent().trim()))
167
+ }
168
+
169
+ // How many blank lines a blank paragraph represents: one per LineBreakNode, but
170
+ // at least one (a freshly-typed empty paragraph has no line breaks yet still
171
+ // stands for a single blank line). This exact count is what keeps multiple
172
+ // blank lines from multiplying on every save — the reopened paragraph holds N
173
+ // line breaks and must re-emit N markers, not N+1.
174
+ function blankParagraphBreakCount(node) {
175
+ const breaks = node.getChildren().filter($isLineBreakNode).length
176
+ return Math.max(1, breaks)
177
+ }
178
+
179
+ // Blank paragraphs -> one `<br>` per blank line. A `<br>` is a semantic empty
180
+ // line (not a stray space or NBSP): the hard-break renderers keep it as a
181
+ // visible blank line, and because each marker sits in its own block separated
182
+ // by the standard `\n\n`, a blank line after a list no longer gets pulled into
183
+ // the last <li> via lazy continuation. Round-trips: the importer regroups the
184
+ // rendered <br> elements into a blank paragraph that re-exports identically.
185
+ const BLANK_PARAGRAPH_TRANSFORMER = exportOnlyTransformer(
186
+ (node) =>
187
+ isBlankParagraph(node) ? Array(blankParagraphBreakCount(node)).fill("<br>").join("\n\n") : null,
188
+ { type: "element" }
189
+ )
190
+
191
+ // Colored / highlighted text -> normalized <span>. Falls through (returns null)
192
+ // for uncolored text so the default text-format export still applies.
193
+ const COLOR_TRANSFORMER = exportOnlyTransformer((node, _exportChildren, exportFormat) => {
194
+ if (!$isTextNode(node)) return null
195
+ const style = node.getStyle ? node.getStyle() : ""
196
+ if (!style) return null
197
+ return colorSpanMarkup(style, exportFormat(node, node.getTextContent()))
198
+ })
199
+
200
+ // Decorator handler registered as a TEXT-MATCH transformer for media that lives
201
+ // INLINE inside a paragraph (claimed during exportChildren).
202
+ const DECORATOR_TEXT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node))
203
+
204
+ // The SAME handler registered as an ELEMENT transformer for media that is a
205
+ // direct child of the root (the upload plugin's no-selection append path, and
206
+ // imported block-level <img> nodes). $convertToMarkdownString only runs element
207
+ // transformers on top-level nodes — a top-level decorator that matches no element
208
+ // transformer falls back to DecoratorNode#getTextContent() (empty for media),
209
+ // silently dropping it from markdown_source. Without this, the first rich save
210
+ // loses every top-level image/video/file.
211
+ const DECORATOR_ELEMENT_TRANSFORMER = exportOnlyTransformer((node) => decoratorMarkup(node), {
212
+ type: "element"
213
+ })
214
+
215
+ // Our custom transformers run first so colored text and decorator nodes are
216
+ // claimed before the upstream defaults (which would drop their style/content).
217
+ export const MARKDOWN_TRANSFORMERS = [
218
+ TABLE,
219
+ DECORATOR_ELEMENT_TRANSFORMER,
220
+ BLANK_PARAGRAPH_TRANSFORMER,
221
+ DECORATOR_TEXT_TRANSFORMER,
222
+ COLOR_TRANSFORMER,
223
+ ...TRANSFORMERS
224
+ ]
225
+
226
+ // Tables serialize their cell content with the same transformer set. Injected
227
+ // here (rather than imported into table_transformer.js) to break the cycle.
228
+ setCellTransformers(MARKDOWN_TRANSFORMERS)
229
+
230
+ // Fenced code blocks may legitimately contain consecutive blank lines; guard
231
+ // them so the blank-line normalization below never touches a code sample.
232
+ const FENCE_BLOCK = /(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*(?=\n|$)/g
233
+
234
+ // Inline code spans (`...`) may legitimately contain the literal text `<br>` or
235
+ // a lone NBSP; guard them too so the blank-line normalization never rewrites the
236
+ // span (which would break it on save). Matched after fences are stashed, so the
237
+ // remaining backtick runs are genuine inline code.
238
+ const INLINE_CODE = /(`+)[^\n]*?\1/g
239
+
240
+ // Normalize the canonical Markdown projection. Enter produces a real paragraph
241
+ // break (standard `\n\n` separation); a blank line the user typed is preserved
242
+ // as a `<br>` marker (see BLANK_PARAGRAPH_TRANSFORMER). This pass only:
243
+ // - returns "" for a document with no real content (only whitespace and/or
244
+ // `<br>` markers), keeping the empty-state placeholder/presence contract,
245
+ // - migrates legacy NBSP-only blank-line markers (pre-`<br>` content) to a
246
+ // real `<br>` marker so they stop acting as a CommonMark lazy continuation,
247
+ // - isolates every `<br>` marker as its own block,
248
+ // - collapses runs of 3+ newlines to one blank line so block separation stays
249
+ // canonical and the very first save is round-trip stable,
250
+ // - trims trailing whitespace.
251
+ // Blank lines and literal `<br>`/NBSP inside code (fenced or inline) are
252
+ // preserved verbatim.
253
+ export function normalizeMarkdownBlankLines(markdown) {
254
+ // A document of only blank lines (each now a `<br>`) carries no real content,
255
+ // so strip the markers before the emptiness check.
256
+ if (!String(markdown).replace(/<br\s*\/?>/gi, "").trim()) return ""
257
+
258
+ const guards = []
259
+ const stash = (match) => `\x00MDGUARD${guards.push(match) - 1}\x00`
260
+ const guarded = String(markdown)
261
+ .replace(FENCE_BLOCK, stash)
262
+ .replace(INLINE_CODE, stash)
263
+
264
+ const normalized = guarded
265
+ // Legacy blank-line markers stored a line of only NBSP (U+00A0). CommonMark
266
+ // treats NBSP as content — a lazy list continuation — re-introducing the
267
+ // list-indent bug, so migrate such lines to a real `<br>` marker on save.
268
+ // (A regular whitespace-only line is already a CommonMark blank line.)
269
+ .replace(/^[ \t]*\u00A0[ \t\u00A0]*$/gm, "<br>")
270
+ // Each blank-line marker is its own block: isolate every `<br>` with a blank
271
+ // line on both sides. Lexical's exporter joins a freshly-typed empty
272
+ // paragraph to its neighbours with a single `\n`, while a reopened (grouped)
273
+ // blank paragraph gets `\n\n`; canonicalizing here makes the very first save
274
+ // byte-identical to the reopen fixpoint regardless of which path produced it.
275
+ // BLANK_PARAGRAPH_TRANSFORMER always emits a marker alone on its line, so we
276
+ // only match a `<br>` that is the WHOLE line (anchored ^…$ under /m). A
277
+ // `<br>` embedded in other content (an in-paragraph hard break or raw inline
278
+ // HTML such as `<span>foo<br>bar</span>`) is not a marker and is left
279
+ // untouched — isolating it would rewrite user HTML on save.
280
+ .replace(/\n*^[ \t]*<br>[ \t]*$\n*/gm, "\n\n<br>\n\n")
281
+ .replace(/\n{3,}/g, "\n\n")
282
+ .replace(/^\n+/, "")
283
+ .replace(/\s+$/, "")
284
+
285
+ return normalized.replace(/\x00MDGUARD(\d+)\x00/g, (_, n) => guards[Number(n)])
286
+ }
287
+
288
+ // On reopen, a run of blank-line markers (<br>) comes back grouped into ONE
289
+ // paragraph holding N LineBreakNodes. Lexical renders such a paragraph as N+1
290
+ // visual lines — each break starts a new line on top of the paragraph's own
291
+ // line — so a single typed blank line reopens as TWO, growing by one on every
292
+ // reopen (the "blank lines keep multiplying" bug). Freshly typed blank lines are EMPTY
293
+ // paragraphs instead (zero children, one visual line each). Re-create that
294
+ // structure: replace every all-LineBreakNode paragraph with N empty paragraphs
295
+ // so reopened blank lines match typed ones. Export is unaffected — each empty
296
+ // paragraph still emits exactly one <br> (blankParagraphBreakCount), so the
297
+ // canonical Markdown round-trips byte-for-byte. Paragraphs that mix text with a
298
+ // break (real soft breaks) are left untouched.
299
+ export function splitBlankLineParagraphs(root) {
300
+ if (!root || !root.getChildren) return
301
+ root.getChildren().forEach((node) => {
302
+ if (!$isParagraphNode(node)) return
303
+ const children = node.getChildren()
304
+ if (children.length === 0) return
305
+ if (!children.every($isLineBreakNode)) return
306
+ for (let i = 0; i < children.length; i++) {
307
+ node.insertBefore($createParagraphNode())
308
+ }
309
+ node.remove()
310
+ })
311
+ }
312
+
313
+ // Read the editor state and return canonical Markdown.
314
+ export function lexicalToMarkdown(editor) {
315
+ let markdown = ""
316
+ editor.getEditorState().read(() => {
317
+ markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS)
318
+ })
319
+ return normalizeMarkdownBlankLines(markdown)
320
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ $isRangeSelection,
3
+ $isTextNode,
4
+ $isRootOrShadowRoot
5
+ } from "lexical"
6
+
7
+ // True when a collapsed selection sits at the very start of the whole document
8
+ // (offset 0 of the first leaf, with no previous siblings up the tree).
9
+ //
10
+ // NOTE: checking `$getCharacterOffsets() === [0, 0]` is NOT enough — that only
11
+ // reports the offset within the current node, so the start of the *second*
12
+ // paragraph (e.g. right after pressing Enter) also reads as [0, 0]. We must walk
13
+ // up the tree confirming there is no earlier content, mirroring
14
+ // isSelectionAtDocumentEnd.
15
+ export function isSelectionAtDocumentStart(selection) {
16
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
17
+
18
+ const anchor = selection.anchor
19
+ let node = anchor.getNode()
20
+ if (!node) return false
21
+
22
+ if (anchor.offset !== 0) return false
23
+
24
+ while (node && !$isRootOrShadowRoot(node)) {
25
+ if (node.getPreviousSibling()) return false
26
+ node = node.getParent()
27
+ }
28
+
29
+ return !!node && $isRootOrShadowRoot(node)
30
+ }
31
+
32
+ // True when a collapsed selection sits at the very end of the whole document
33
+ // (end of the last leaf, with no following siblings up the tree).
34
+ export function isSelectionAtDocumentEnd(selection) {
35
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
36
+
37
+ const focus = selection.focus
38
+ let node = focus.getNode()
39
+ if (!node) return false
40
+
41
+ const offset = focus.offset
42
+ if ($isTextNode(node)) {
43
+ if (offset !== node.getTextContentSize()) return false
44
+ } else if (typeof node.getChildrenSize === "function") {
45
+ if (offset !== node.getChildrenSize()) return false
46
+ } else {
47
+ // Fallback for nodes without children size (e.g., line breaks)
48
+ const textSize = node.getTextContentSize?.() ?? 0
49
+ if (offset !== textSize) return false
50
+ }
51
+
52
+ while (node && !$isRootOrShadowRoot(node)) {
53
+ if (node.getNextSibling()) return false
54
+ node = node.getParent()
55
+ }
56
+
57
+ return !!node && $isRootOrShadowRoot(node)
58
+ }
@@ -0,0 +1,182 @@
1
+ import {
2
+ $createTableCellNode,
3
+ $createTableNode,
4
+ $createTableRowNode,
5
+ $isTableCellNode,
6
+ $isTableNode,
7
+ $isTableRowNode,
8
+ TableCellHeaderStates
9
+ } from "@lexical/table"
10
+ import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"
11
+ import { $isParagraphNode, $isTextNode } from "lexical"
12
+
13
+ // GFM pipe-table support for the markdown-canonical editor. Ported from the
14
+ // Lexical playground's MarkdownTransformers TABLE ElementTransformer, adapted to
15
+ // .js and to a lazily-injected cell transformer list. Unlike the color/decorator
16
+ // transformers (export-only), this one is bidirectional: it exports Lexical
17
+ // tables to GFM and reconstructs them when markdown is parsed back.
18
+
19
+ const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/
20
+ // -+ (not -*) is required per cell: a GFM divider must have at least one dash,
21
+ // and the mandatory dash run anchors the surrounding :? colons so they can't both
22
+ // compete for the same character — which would cause exponential backtracking.
23
+ const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-+:? ?)+\|\s?$/
24
+
25
+ // Each table cell is (de)serialized with the full transformer list (which
26
+ // includes this TABLE transformer). markdown_serialize.js injects the list once
27
+ // at load via setCellTransformers to avoid a circular import. Cells never nest
28
+ // tables, so the self-reference stays bounded.
29
+ let cellTransformers = []
30
+ export function setCellTransformers(list) {
31
+ cellTransformers = list
32
+ }
33
+
34
+ function getTableColumnsSize(table) {
35
+ const row = table.getFirstChild()
36
+ return $isTableRowNode(row) ? row.getChildrenSize() : 0
37
+ }
38
+
39
+ function $createTableCell(textContent) {
40
+ // Backslashes are governed by CommonMark and round-trip through the per-cell
41
+ // $convertFromMarkdownString below; we must NOT pre-decode them here. An earlier
42
+ // ".replace(/\\n/g, \"\\n\")" mis-fired on literal "a\nb" (backslash + n typed by
43
+ // the user, e.g. a regex or Windows path), corrupting it into a real newline
44
+ // that breaks the single-line row (CodeQL alert #42). Cells are single-line, so
45
+ // no newline decoding is needed at all.
46
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
47
+ $convertFromMarkdownString(textContent, cellTransformers, cell)
48
+ return cell
49
+ }
50
+
51
+ // Split a row's inner text on cell boundaries and reverse the export escaping in
52
+ // one left-to-right pass. Export escapes (in order) backslash -> "\\" then pipe
53
+ // -> "\|", so here every backslash starts a 2-char escape: it consumes the next
54
+ // char ("\\" -> "\", "\|" -> a literal in-cell pipe). Only a *bare* pipe is a
55
+ // column boundary. The result still carries CommonMark's own backslash escapes,
56
+ // which $convertFromMarkdownString decodes per cell. Undoing the backslash escape
57
+ // here (not just "\|") is what keeps the escaping complete and unambiguous
58
+ // (CodeQL js/incomplete-sanitization #43). Each cell is trimmed because GFM treats
59
+ // the spaces around "| cell |" as insignificant padding (export re-adds them).
60
+ function splitRowCells(inner) {
61
+ const cells = []
62
+ let current = ""
63
+ for (let i = 0; i < inner.length; i++) {
64
+ if (inner[i] === "\\" && i + 1 < inner.length) {
65
+ current += inner[i + 1] // "\\" -> "\", "\|" -> "|" (any other "\x" -> "x")
66
+ i++
67
+ } else if (inner[i] === "|") {
68
+ cells.push(current.trim())
69
+ current = ""
70
+ } else {
71
+ current += inner[i]
72
+ }
73
+ }
74
+ cells.push(current.trim())
75
+ return cells
76
+ }
77
+
78
+ function mapToTableCells(textContent) {
79
+ const match = textContent.match(TABLE_ROW_REG_EXP)
80
+ if (!match || !match[1]) return null
81
+ return splitRowCells(match[1]).map((text) => $createTableCell(text))
82
+ }
83
+
84
+ export const TABLE = {
85
+ dependencies: [],
86
+ export: (node) => {
87
+ if (!$isTableNode(node)) return null
88
+ const output = []
89
+ for (const row of node.getChildren()) {
90
+ if (!$isTableRowNode(row)) continue
91
+ const rowOutput = []
92
+ let isHeaderRow = false
93
+ for (const cell of row.getChildren()) {
94
+ if ($isTableCellNode(cell)) {
95
+ rowOutput.push(
96
+ $convertToMarkdownString(cellTransformers, cell)
97
+ // A GFM row is one line, so collapse any real newline to a space
98
+ // (multi-line cells aren't representable in GFM anyway). NOT "\\n":
99
+ // that re-imported as a literal backslash-n and then mis-decoded
100
+ // back to a newline, corrupting genuine "a\nb" text (CodeQL #42).
101
+ .replace(/\n+/g, " ")
102
+ // Escape the escape char FIRST, then pipes, so the escaping is
103
+ // complete and unambiguous (CodeQL js/incomplete-sanitization #43).
104
+ // splitRowCells reverses both ("\\"->"\", "\|"->"|") on import,
105
+ // restoring exactly what CommonMark emitted before re-decoding it.
106
+ .replace(/\\/g, "\\\\")
107
+ .replace(/\|/g, "\\|")
108
+ .trim()
109
+ )
110
+ if (cell.__headerState === TableCellHeaderStates.ROW) {
111
+ isHeaderRow = true
112
+ }
113
+ }
114
+ }
115
+ output.push(`| ${rowOutput.join(" | ")} |`)
116
+ if (isHeaderRow) {
117
+ output.push(`| ${rowOutput.map(() => "---").join(" | ")} |`)
118
+ }
119
+ }
120
+ return output.join("\n")
121
+ },
122
+ regExp: TABLE_ROW_REG_EXP,
123
+ replace: (parentNode, _children, match) => {
124
+ // A divider row ("| --- | --- |") promotes the previous table's last row to
125
+ // a header row, then removes itself.
126
+ if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
127
+ const table = parentNode.getPreviousSibling()
128
+ if (!table || !$isTableNode(table)) return
129
+ const rows = table.getChildren()
130
+ const lastRow = rows[rows.length - 1]
131
+ if (!lastRow || !$isTableRowNode(lastRow)) return
132
+ lastRow.getChildren().forEach((cell) => {
133
+ if (!$isTableCellNode(cell)) return
134
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW)
135
+ })
136
+ parentNode.remove()
137
+ return
138
+ }
139
+
140
+ const matchCells = mapToTableCells(match[0])
141
+ if (matchCells == null) return
142
+
143
+ // Walk backwards over preceding single-text paragraphs that are also table
144
+ // rows, accumulating them so a multi-line table is parsed as one node.
145
+ const rows = [matchCells]
146
+ let sibling = parentNode.getPreviousSibling()
147
+ let maxCells = matchCells.length
148
+ while (sibling) {
149
+ if (!$isParagraphNode(sibling) || sibling.getChildrenSize() !== 1) break
150
+ const firstChild = sibling.getFirstChild()
151
+ if (!$isTextNode(firstChild)) break
152
+ const cells = mapToTableCells(firstChild.getTextContent())
153
+ if (cells == null) break
154
+ maxCells = Math.max(maxCells, cells.length)
155
+ rows.unshift(cells)
156
+ const previousSibling = sibling.getPreviousSibling()
157
+ sibling.remove()
158
+ sibling = previousSibling
159
+ }
160
+
161
+ const table = $createTableNode()
162
+ for (const cells of rows) {
163
+ const tableRow = $createTableRowNode()
164
+ table.append(tableRow)
165
+ for (let i = 0; i < maxCells; i++) {
166
+ tableRow.append(i < cells.length ? cells[i] : $createTableCell(""))
167
+ }
168
+ }
169
+
170
+ // Merge with an adjacent table of the same width (the divider row already
171
+ // removed itself, leaving the header table directly before this body row).
172
+ const previousSibling = parentNode.getPreviousSibling()
173
+ if ($isTableNode(previousSibling) && getTableColumnsSize(previousSibling) === maxCells) {
174
+ previousSibling.append(...table.getChildren())
175
+ parentNode.remove()
176
+ } else {
177
+ parentNode.replace(table)
178
+ }
179
+ table.selectEnd()
180
+ },
181
+ type: "element"
182
+ }
@@ -0,0 +1,29 @@
1
+ import { $createParagraphNode, $isDecoratorNode, RootNode } from "lexical"
2
+
3
+ // A top-level DecoratorNode (image / video / attachment) renders as
4
+ // non-editable content. When one is the last child of the root there is no
5
+ // text position after it, so the caret can't land below it and the whole
6
+ // document becomes uneditable — the reported "an image steals focus / can't
7
+ // edit" bug, worst when the image is the only block. Guarantee the root always
8
+ // ends in a paragraph so there is always an editable landing spot after a
9
+ // trailing decorator.
10
+ //
11
+ // Must run inside an editor.update()/transform.
12
+ export function ensureTrailingParagraph(root) {
13
+ const last = root.getLastChild()
14
+ if (last !== null && $isDecoratorNode(last)) {
15
+ last.insertAfter($createParagraphNode())
16
+ return true
17
+ }
18
+ return false
19
+ }
20
+
21
+ // Keep the guarantee while editing too: deleting the text below an image, or
22
+ // pasting an image as the last block, would otherwise re-trap the caret.
23
+ // Appending leaves the root dirty, so the transform re-runs once more and then
24
+ // no-ops (last child is now the paragraph) — it terminates.
25
+ export function registerTrailingParagraph(editor) {
26
+ return editor.registerNodeTransform(RootNode, (root) => {
27
+ ensureTrailingParagraph(root)
28
+ })
29
+ }
@@ -0,0 +1,46 @@
1
+ // turbo_confirm — route Turbo's declarative `data-turbo-confirm` prompts through
2
+ // the in-app confirm dialog instead of the native window.confirm().
3
+ //
4
+ // Why: Rails/Turbo forms gate destructive submits with `data-turbo-confirm`.
5
+ // Turbo's default confirm method calls window.confirm(), which in the packaged
6
+ // desktop app (Tauri WKWebView) returns false with no UI — so the submit
7
+ // silently no-ops (e.g. token revoke, user delete, share removal) and looks
8
+ // like the button is broken. Converting hand-written confirm() calls does not
9
+ // cover these, because Turbo intercepts them before any of our code runs. The
10
+ // fix is a single global override of Turbo's confirm method.
11
+ //
12
+ // Returns a Promise<boolean>; Turbo awaits it before submitting (Turbo 8
13
+ // `config.forms.confirm`, with a fallback to the deprecated setConfirmMethod).
14
+
15
+ import { Turbo } from "@hotwired/turbo-rails"
16
+ import { confirmDialog } from "./utils/dialog"
17
+
18
+ // Turbo invokes this with (message, formElement, submitter). Style the confirm
19
+ // button as destructive when the submit deletes (the common case for
20
+ // data-turbo-confirm), so it matches the danger styling used elsewhere.
21
+ function turboConfirm(message, formElement, submitter) {
22
+ const method = (
23
+ submitter?.getAttribute?.("data-turbo-method") ||
24
+ formElement?.getAttribute?.("data-turbo-method") ||
25
+ formElement?.querySelector?.("input[name='_method']")?.value ||
26
+ formElement?.getAttribute?.("method") ||
27
+ ""
28
+ ).toLowerCase()
29
+ const danger = method === "delete" || method === "destroy"
30
+ return confirmDialog(message, { danger })
31
+ }
32
+
33
+ // Install on both the bundled Turbo and any pre-existing global window.Turbo,
34
+ // mirroring turbo_stream_actions.js — the two can be distinct instances.
35
+ function installTurboConfirm(turbo) {
36
+ if (!turbo) return
37
+ if (turbo.config && turbo.config.forms) {
38
+ turbo.config.forms.confirm = turboConfirm
39
+ } else if (typeof turbo.setConfirmMethod === "function") {
40
+ // Turbo < 8 fallback.
41
+ turbo.setConfirmMethod(turboConfirm)
42
+ }
43
+ }
44
+
45
+ installTurboConfirm(Turbo)
46
+ if (window.Turbo && window.Turbo !== Turbo) installTurboConfirm(window.Turbo)