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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- 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, "&")
|
|
54
|
+
.replace(/"/g, """)
|
|
55
|
+
.replace(/</g, "<")
|
|
56
|
+
.replace(/>/g, ">")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeHtmlText(value) {
|
|
60
|
+
return String(value == null ? "" : value)
|
|
61
|
+
.replace(/&/g, "&")
|
|
62
|
+
.replace(/</g, "<")
|
|
63
|
+
.replace(/>/g, ">")
|
|
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)
|