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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dialog — Promise-based, in-app replacements for the native browser dialogs
|
|
3
|
+
* window.alert(), window.confirm() and window.prompt().
|
|
4
|
+
*
|
|
5
|
+
* Why: the packaged desktop app runs inside a Tauri (WKWebView) webview, which
|
|
6
|
+
* does not reliably present native dialogs — alert() shows nothing, confirm()
|
|
7
|
+
* returns false with no UI, and prompt() returns null. Every action gated on
|
|
8
|
+
* `if (confirm(...))` then silently no-ops, and every `alert(error)` swallows
|
|
9
|
+
* the message, so a failure looks like data loss. Rendering our own DOM modal
|
|
10
|
+
* behaves identically in a browser and in the webview, removing the native
|
|
11
|
+
* dialog dependency entirely.
|
|
12
|
+
*
|
|
13
|
+
* Public API (all return Promises so callers `await` them):
|
|
14
|
+
* await alertDialog(message) → undefined (single OK button)
|
|
15
|
+
* await confirmDialog(message, { danger }) → boolean (OK / Cancel)
|
|
16
|
+
* await promptDialog(message, { defaultValue })→ string|null (input + OK / Cancel)
|
|
17
|
+
*
|
|
18
|
+
* Reuses the shared .modal-dialog styles (modal_dialog.css) so any host app or
|
|
19
|
+
* engine that bundles the collavre stylesheet renders these consistently.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Localized default button labels. Dialog *messages* are localized by the
|
|
23
|
+
// caller (server-side strings / data attributes); only the generic OK/Cancel
|
|
24
|
+
// buttons need a locale here. Locale comes from <html lang>, falling back to
|
|
25
|
+
// the webview/browser language.
|
|
26
|
+
const LABELS = {
|
|
27
|
+
ko: { ok: '확인', cancel: '취소' },
|
|
28
|
+
en: { ok: 'OK', cancel: 'Cancel' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function localeLabels() {
|
|
32
|
+
const lang = (
|
|
33
|
+
document.documentElement.lang ||
|
|
34
|
+
navigator.language ||
|
|
35
|
+
'en'
|
|
36
|
+
).toLowerCase()
|
|
37
|
+
const key = Object.keys(LABELS).find((l) => lang.startsWith(l))
|
|
38
|
+
return LABELS[key] || LABELS.en
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Stack of open dialogs so keyboard handling only targets the topmost one.
|
|
42
|
+
const stack = []
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Internal modal builder shared by alert/confirm/prompt.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} config
|
|
48
|
+
* @param {string} config.message Body text (rendered as text, never HTML).
|
|
49
|
+
* @param {string|null} config.title Optional header title.
|
|
50
|
+
* @param {boolean} config.danger Style the confirm button as destructive.
|
|
51
|
+
* @param {boolean} config.showCancel Render a Cancel button (confirm/prompt).
|
|
52
|
+
* @param {string} config.confirmText Confirm button label.
|
|
53
|
+
* @param {string} config.cancelText Cancel button label.
|
|
54
|
+
* @param {object|null} config.input When set, render a text input:
|
|
55
|
+
* { defaultValue?: string, placeholder?: string }
|
|
56
|
+
* @returns {Promise<{ confirmed: boolean, value: string|null }>}
|
|
57
|
+
* confirmed=true on OK/Enter; false on Cancel/Escape/overlay dismiss.
|
|
58
|
+
* value=current input text (always '' when no input is rendered).
|
|
59
|
+
*/
|
|
60
|
+
function runDialog(config) {
|
|
61
|
+
const labels = localeLabels()
|
|
62
|
+
const {
|
|
63
|
+
message,
|
|
64
|
+
title = null,
|
|
65
|
+
danger = false,
|
|
66
|
+
showCancel = false,
|
|
67
|
+
confirmText = labels.ok,
|
|
68
|
+
cancelText = labels.cancel,
|
|
69
|
+
input = null,
|
|
70
|
+
} = config
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const previousFocus = document.activeElement
|
|
74
|
+
|
|
75
|
+
const overlay = document.createElement('div')
|
|
76
|
+
overlay.className = 'modal-dialog-overlay open'
|
|
77
|
+
|
|
78
|
+
// A real <dialog> (not a <div>): showModal() promotes it into the browser
|
|
79
|
+
// top layer so it renders ABOVE native <dialog> lightboxes (also top layer)
|
|
80
|
+
// and z-index:9999 fullscreen popups. A plain z-index can never beat the
|
|
81
|
+
// top layer, so an in-app confirm gated behind those surfaces would be
|
|
82
|
+
// invisible and look hung — the exact silent-no-op failure this module
|
|
83
|
+
// exists to remove.
|
|
84
|
+
const dialog = document.createElement('dialog')
|
|
85
|
+
dialog.className = 'modal-dialog modal-dialog-compact open'
|
|
86
|
+
dialog.setAttribute('role', 'alertdialog')
|
|
87
|
+
dialog.setAttribute('aria-modal', 'true')
|
|
88
|
+
|
|
89
|
+
if (title) {
|
|
90
|
+
const header = document.createElement('div')
|
|
91
|
+
header.className = 'modal-dialog-header'
|
|
92
|
+
const titleEl = document.createElement('div')
|
|
93
|
+
titleEl.className = 'modal-dialog-title'
|
|
94
|
+
titleEl.textContent = title
|
|
95
|
+
header.appendChild(titleEl)
|
|
96
|
+
dialog.appendChild(header)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const body = document.createElement('div')
|
|
100
|
+
body.className = 'modal-dialog-body'
|
|
101
|
+
|
|
102
|
+
// Message text. textContent (not innerHTML): the message may contain
|
|
103
|
+
// untrusted content, and native dialogs never rendered HTML either.
|
|
104
|
+
// white-space: pre-wrap (CSS) preserves the \n line breaks they honored.
|
|
105
|
+
if (message != null && String(message) !== '') {
|
|
106
|
+
const text = document.createElement('div')
|
|
107
|
+
text.className = 'confirm-dialog-message'
|
|
108
|
+
text.textContent = String(message)
|
|
109
|
+
body.appendChild(text)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Optional input field (promptDialog).
|
|
113
|
+
let inputEl = null
|
|
114
|
+
if (input) {
|
|
115
|
+
inputEl = document.createElement('input')
|
|
116
|
+
inputEl.type = 'text'
|
|
117
|
+
inputEl.className = 'modal-dialog-input'
|
|
118
|
+
if (input.placeholder) inputEl.placeholder = input.placeholder
|
|
119
|
+
inputEl.value = input.defaultValue != null ? String(input.defaultValue) : ''
|
|
120
|
+
// Spacing when a message precedes the input.
|
|
121
|
+
if (body.childNodes.length > 0) inputEl.style.marginTop = 'var(--space-3)'
|
|
122
|
+
body.appendChild(inputEl)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dialog.appendChild(body)
|
|
126
|
+
|
|
127
|
+
const footer = document.createElement('div')
|
|
128
|
+
footer.className = 'modal-dialog-footer'
|
|
129
|
+
|
|
130
|
+
let cancelBtn = null
|
|
131
|
+
if (showCancel) {
|
|
132
|
+
cancelBtn = document.createElement('button')
|
|
133
|
+
cancelBtn.type = 'button'
|
|
134
|
+
cancelBtn.className = 'modal-dialog-btn modal-dialog-btn-secondary'
|
|
135
|
+
cancelBtn.textContent = cancelText
|
|
136
|
+
footer.appendChild(cancelBtn)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const confirmBtn = document.createElement('button')
|
|
140
|
+
confirmBtn.type = 'button'
|
|
141
|
+
confirmBtn.className =
|
|
142
|
+
'modal-dialog-btn ' +
|
|
143
|
+
(danger ? 'modal-dialog-btn-danger' : 'modal-dialog-btn-primary')
|
|
144
|
+
confirmBtn.textContent = confirmText
|
|
145
|
+
footer.appendChild(confirmBtn)
|
|
146
|
+
|
|
147
|
+
dialog.appendChild(footer)
|
|
148
|
+
|
|
149
|
+
const entry = { dialog }
|
|
150
|
+
let settled = false
|
|
151
|
+
|
|
152
|
+
function close(confirmed) {
|
|
153
|
+
if (settled) return
|
|
154
|
+
settled = true
|
|
155
|
+
const value = inputEl ? inputEl.value : ''
|
|
156
|
+
const i = stack.indexOf(entry)
|
|
157
|
+
if (i !== -1) stack.splice(i, 1)
|
|
158
|
+
document.removeEventListener('keydown', onKeydown, true)
|
|
159
|
+
overlay.remove() // no-op when never appended (top-layer path)
|
|
160
|
+
// Exit the top layer before detaching (close event is not observed here).
|
|
161
|
+
try {
|
|
162
|
+
if (dialog.open && typeof dialog.close === 'function') dialog.close()
|
|
163
|
+
} catch (_) {
|
|
164
|
+
/* unsupported — removal below still drops it from the top layer */
|
|
165
|
+
}
|
|
166
|
+
dialog.remove()
|
|
167
|
+
// Restore focus to whatever triggered the dialog.
|
|
168
|
+
try {
|
|
169
|
+
if (previousFocus && typeof previousFocus.focus === 'function') {
|
|
170
|
+
previousFocus.focus()
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
/* element gone — ignore */
|
|
174
|
+
}
|
|
175
|
+
resolve({ confirmed, value })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onKeydown(e) {
|
|
179
|
+
// Only the topmost dialog reacts to keys.
|
|
180
|
+
if (stack[stack.length - 1] !== entry) return
|
|
181
|
+
if (e.key === 'Escape') {
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
e.stopPropagation()
|
|
184
|
+
close(false)
|
|
185
|
+
} else if (e.key === 'Enter') {
|
|
186
|
+
// In a text input, Enter submits (parity with native prompt).
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
close(true)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (cancelBtn) cancelBtn.addEventListener('click', () => close(false))
|
|
194
|
+
confirmBtn.addEventListener('click', () => close(true))
|
|
195
|
+
// Click on the ::backdrop (its event target is the <dialog> itself, since
|
|
196
|
+
// the panel content is nested) dismisses — parity with the old overlay.
|
|
197
|
+
dialog.addEventListener('click', (e) => {
|
|
198
|
+
if (e.target === dialog) close(false)
|
|
199
|
+
})
|
|
200
|
+
// Escape on a modal <dialog> fires `cancel`; route it through our teardown
|
|
201
|
+
// (focus restore + promise resolution) instead of the bare native close.
|
|
202
|
+
dialog.addEventListener('cancel', (e) => {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
close(false)
|
|
205
|
+
})
|
|
206
|
+
document.addEventListener('keydown', onKeydown, true)
|
|
207
|
+
|
|
208
|
+
document.body.appendChild(dialog)
|
|
209
|
+
stack.push(entry)
|
|
210
|
+
|
|
211
|
+
// Promote into the top layer. Fall back to a z-index overlay backdrop only
|
|
212
|
+
// where showModal is unavailable (jsdom under tests, very old webviews).
|
|
213
|
+
let topLayer = false
|
|
214
|
+
try {
|
|
215
|
+
dialog.showModal()
|
|
216
|
+
topLayer = true
|
|
217
|
+
} catch (_) {
|
|
218
|
+
/* no showModal — manual backdrop + stacking below */
|
|
219
|
+
}
|
|
220
|
+
if (!topLayer) {
|
|
221
|
+
dialog.setAttribute('open', '')
|
|
222
|
+
overlay.addEventListener('click', () => close(false))
|
|
223
|
+
document.body.insertBefore(overlay, dialog)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Focus the input (prompt) or the confirm button (alert/confirm) so Enter
|
|
227
|
+
// accepts, matching native dialog behavior.
|
|
228
|
+
if (inputEl) {
|
|
229
|
+
inputEl.focus()
|
|
230
|
+
inputEl.select()
|
|
231
|
+
} else {
|
|
232
|
+
confirmBtn.focus()
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* alertDialog — in-app replacement for window.alert().
|
|
239
|
+
* Resolves (undefined) when dismissed. Usually fire-and-forget at call sites,
|
|
240
|
+
* but returns a Promise so callers may `await` it when ordering matters.
|
|
241
|
+
*
|
|
242
|
+
* alertDialog('Failed to save')
|
|
243
|
+
* await alertDialog('Done', { title: 'Export' })
|
|
244
|
+
*/
|
|
245
|
+
export function alertDialog(message, options = {}) {
|
|
246
|
+
const { title = null, confirmText, danger = false } = options
|
|
247
|
+
return runDialog({
|
|
248
|
+
message,
|
|
249
|
+
title,
|
|
250
|
+
danger,
|
|
251
|
+
showCancel: false,
|
|
252
|
+
confirmText,
|
|
253
|
+
}).then(() => undefined)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* confirmDialog — in-app replacement for window.confirm().
|
|
258
|
+
* Resolves true when confirmed, false when cancelled/dismissed.
|
|
259
|
+
*
|
|
260
|
+
* if (await confirmDialog(message, { danger: true })) doDestructiveThing()
|
|
261
|
+
*/
|
|
262
|
+
export function confirmDialog(message, options = {}) {
|
|
263
|
+
const { confirmText, cancelText, danger = false, title = null } = options
|
|
264
|
+
return runDialog({
|
|
265
|
+
message,
|
|
266
|
+
title,
|
|
267
|
+
danger,
|
|
268
|
+
showCancel: true,
|
|
269
|
+
confirmText,
|
|
270
|
+
cancelText,
|
|
271
|
+
}).then((r) => r.confirmed)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* promptDialog — in-app replacement for window.prompt().
|
|
276
|
+
* Resolves the entered string when confirmed, or null when cancelled/dismissed
|
|
277
|
+
* (parity with native prompt, which returns null on cancel).
|
|
278
|
+
*
|
|
279
|
+
* const name = await promptDialog('New name?', { defaultValue: current })
|
|
280
|
+
* if (name !== null) rename(name)
|
|
281
|
+
*/
|
|
282
|
+
export function promptDialog(message, options = {}) {
|
|
283
|
+
const {
|
|
284
|
+
defaultValue = '',
|
|
285
|
+
placeholder = '',
|
|
286
|
+
confirmText,
|
|
287
|
+
cancelText,
|
|
288
|
+
title = null,
|
|
289
|
+
} = options
|
|
290
|
+
return runDialog({
|
|
291
|
+
message,
|
|
292
|
+
title,
|
|
293
|
+
showCancel: true,
|
|
294
|
+
confirmText,
|
|
295
|
+
cancelText,
|
|
296
|
+
input: { defaultValue, placeholder },
|
|
297
|
+
}).then((r) => (r.confirmed ? r.value : null))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default confirmDialog
|
|
@@ -1,66 +1,39 @@
|
|
|
1
1
|
import { marked } from 'marked'
|
|
2
2
|
import DOMPurify from 'dompurify'
|
|
3
3
|
import { addTableDownloadButtons } from './table_download'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
hljs.registerLanguage('yml', yaml)
|
|
38
|
-
hljs.registerLanguage('bash', bash)
|
|
39
|
-
hljs.registerLanguage('sh', bash)
|
|
40
|
-
hljs.registerLanguage('shell', bash)
|
|
41
|
-
hljs.registerLanguage('sql', sql)
|
|
42
|
-
hljs.registerLanguage('markdown', markdownLang)
|
|
43
|
-
hljs.registerLanguage('md', markdownLang)
|
|
44
|
-
hljs.registerLanguage('diff', diff)
|
|
45
|
-
hljs.registerLanguage('erb', erb)
|
|
46
|
-
hljs.registerLanguage('go', go)
|
|
47
|
-
hljs.registerLanguage('java', java)
|
|
48
|
-
hljs.registerLanguage('plaintext', plaintext)
|
|
49
|
-
hljs.registerLanguage('text', plaintext)
|
|
50
|
-
|
|
51
|
-
function highlightCode(code, lang) {
|
|
52
|
-
if (lang && hljs.getLanguage(lang)) {
|
|
53
|
-
try {
|
|
54
|
-
return hljs.highlight(code, { language: lang }).value
|
|
55
|
-
} catch (_) { /* fall through */ }
|
|
56
|
-
}
|
|
57
|
-
// Auto-detect for unlabeled code blocks
|
|
58
|
-
try {
|
|
59
|
-
return hljs.highlightAuto(code).value
|
|
60
|
-
} catch (_) {
|
|
61
|
-
return code
|
|
62
|
-
}
|
|
63
|
-
}
|
|
4
|
+
|
|
5
|
+
// Prism syntax highlighting for ALL rendered code blocks. The Lexical editor
|
|
6
|
+
// highlights code with Prism (@lexical/code) and tags each token with a
|
|
7
|
+
// `lexical-token-*` class (see lib/editor/code_token_theme.js). Every other
|
|
8
|
+
// surface that renders a fenced block — the rendered creative description, the
|
|
9
|
+
// markdown-mode preview, and chat/comments — tokenizes with the SAME Prism
|
|
10
|
+
// instance, the SAME language components @lexical/code loads, and the SAME
|
|
11
|
+
// token→class map, so a code block looks identical everywhere. The shared
|
|
12
|
+
// code_languages module below registers the extra grammars @lexical/code omits
|
|
13
|
+
// (ruby, bash, …) on the same Prism singleton and resolves each block's language
|
|
14
|
+
// identically to the editor, so the tokenizers stay aligned.
|
|
15
|
+
import Prism from 'prismjs'
|
|
16
|
+
import 'prismjs/components/prism-clike'
|
|
17
|
+
import 'prismjs/components/prism-javascript'
|
|
18
|
+
import 'prismjs/components/prism-markup'
|
|
19
|
+
import 'prismjs/components/prism-markdown'
|
|
20
|
+
import 'prismjs/components/prism-c'
|
|
21
|
+
import 'prismjs/components/prism-css'
|
|
22
|
+
import 'prismjs/components/prism-objectivec'
|
|
23
|
+
import 'prismjs/components/prism-sql'
|
|
24
|
+
import 'prismjs/components/prism-powershell'
|
|
25
|
+
import 'prismjs/components/prism-python'
|
|
26
|
+
import 'prismjs/components/prism-rust'
|
|
27
|
+
import 'prismjs/components/prism-swift'
|
|
28
|
+
import 'prismjs/components/prism-typescript'
|
|
29
|
+
import 'prismjs/components/prism-java'
|
|
30
|
+
import 'prismjs/components/prism-cpp'
|
|
31
|
+
import { CODE_TOKEN_THEME } from '../editor/code_token_theme'
|
|
32
|
+
import { detectCodeLanguage, normalizeFenceLang } from '../editor/code_languages'
|
|
33
|
+
|
|
34
|
+
// We tokenize manually; stop Prism from auto-highlighting `code[class*=language-]`
|
|
35
|
+
// on DOMContentLoaded (which would double-process comment code blocks).
|
|
36
|
+
Prism.manual = true
|
|
64
37
|
|
|
65
38
|
// Sanitize language identifier to prevent class attribute injection
|
|
66
39
|
function sanitizeLang(lang) {
|
|
@@ -68,6 +41,12 @@ function sanitizeLang(lang) {
|
|
|
68
41
|
return lang.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
69
42
|
}
|
|
70
43
|
|
|
44
|
+
// `breaks: true` so a single newline renders as <br> (GitHub/Slack style),
|
|
45
|
+
// matching the canonical markdown_source where consecutive rich-editor lines are
|
|
46
|
+
// stored one-per-line instead of separated by a blank line. Applies app-wide
|
|
47
|
+
// (creative descriptions and comments) so a line break always means a line break.
|
|
48
|
+
marked.use({ breaks: true })
|
|
49
|
+
|
|
71
50
|
// Custom renderer for code blocks with syntax highlighting + mermaid
|
|
72
51
|
marked.use({
|
|
73
52
|
renderer: {
|
|
@@ -77,27 +56,38 @@ marked.use({
|
|
|
77
56
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
78
57
|
return `<div class="mermaid-chart">${escaped}</div>`
|
|
79
58
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
// Highlight with the SAME Prism engine + `lexical-token-*` classes the
|
|
60
|
+
// editor and rendered creative description use (see highlightToLexicalHtml),
|
|
61
|
+
// so a fenced block is colored identically across the markdown-mode preview,
|
|
62
|
+
// chat/comments, the editor, and the rendered creative — one engine, one
|
|
63
|
+
// palette. An explicit fence language is honored verbatim (matching the
|
|
64
|
+
// editor); only genuinely unlabeled blocks are content-detected.
|
|
65
|
+
const resolved = safeLang ? normalizeFenceLang(safeLang) : detectCodeLanguage(text, '')
|
|
66
|
+
const highlighted = highlightToLexicalHtml(text, resolved)
|
|
67
|
+
const langAttr = resolved ? ` lang="${resolved}"` : ''
|
|
68
|
+
const codeClass = resolved ? ` class="language-${resolved}"` : ''
|
|
69
|
+
return `<pre${langAttr}><code${codeClass}>${highlighted}</code></pre>`
|
|
83
70
|
}
|
|
84
71
|
}
|
|
85
72
|
})
|
|
86
73
|
|
|
87
|
-
// Allow
|
|
74
|
+
// Allow the shared Prism `lexical-token-*` span classes through DOMPurify
|
|
88
75
|
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
89
76
|
if (node.tagName === 'SPAN' && data.attrName === 'class') {
|
|
90
77
|
const classes = data.attrValue.split(/\s+/)
|
|
91
|
-
|
|
78
|
+
// `lexical-token-*` is the single token-class family every surface now emits
|
|
79
|
+
// (editor, rendered creative, markdown preview, comments) so they highlight
|
|
80
|
+
// identically.
|
|
81
|
+
const safe = classes.filter(c => c.startsWith('lexical-token-'))
|
|
92
82
|
if (safe.length > 0) {
|
|
93
83
|
data.attrValue = safe.join(' ')
|
|
94
84
|
data.forceKeepAttr = true
|
|
95
85
|
}
|
|
96
86
|
}
|
|
97
|
-
// Allow
|
|
87
|
+
// Allow language-* classes on code elements (the language hint Prism reads)
|
|
98
88
|
if (node.tagName === 'CODE' && data.attrName === 'class') {
|
|
99
89
|
const classes = data.attrValue.split(/\s+/)
|
|
100
|
-
const safe = classes.filter(c => c
|
|
90
|
+
const safe = classes.filter(c => c.startsWith('language-'))
|
|
101
91
|
if (safe.length > 0) {
|
|
102
92
|
data.attrValue = safe.join(' ')
|
|
103
93
|
data.forceKeepAttr = true
|
|
@@ -137,6 +127,103 @@ export function renderCommentMarkdown(text) {
|
|
|
137
127
|
return sanitize(html.trim())
|
|
138
128
|
}
|
|
139
129
|
|
|
130
|
+
function escapeHtml(text) {
|
|
131
|
+
return text
|
|
132
|
+
.replace(/&/g, '&')
|
|
133
|
+
.replace(/</g, '<')
|
|
134
|
+
.replace(/>/g, '>')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wrapToken(text, type) {
|
|
138
|
+
const cls = type ? CODE_TOKEN_THEME[type] : undefined
|
|
139
|
+
if (!cls) return escapeHtml(text)
|
|
140
|
+
// Mirror @lexical/code, which splits a token's text on newlines/tabs into
|
|
141
|
+
// separate highlight nodes (the whitespace between them carries no token
|
|
142
|
+
// class), so the rendered span structure matches the editor exactly — not
|
|
143
|
+
// just the colors.
|
|
144
|
+
return text
|
|
145
|
+
.split(/(\n|\t)/)
|
|
146
|
+
.map((piece) =>
|
|
147
|
+
piece === '\n' || piece === '\t' || piece === ''
|
|
148
|
+
? escapeHtml(piece)
|
|
149
|
+
: `<span class="${cls}">${escapeHtml(piece)}</span>`
|
|
150
|
+
)
|
|
151
|
+
.join('')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Flatten a Prism token stream into `lexical-token-*` span HTML, mirroring how
|
|
155
|
+
// @lexical/code's registerCodeHighlighting assigns exactly ONE class (the
|
|
156
|
+
// nearest enclosing token type) to each leaf text node. `type` is the enclosing
|
|
157
|
+
// token type handed down to bare string leaves, so the rendered view tokenizes
|
|
158
|
+
// and classes identically to the editor.
|
|
159
|
+
function tokensToLexicalHtml(tokens, type) {
|
|
160
|
+
let html = ''
|
|
161
|
+
for (const token of tokens) {
|
|
162
|
+
if (typeof token === 'string') {
|
|
163
|
+
html += wrapToken(token, type)
|
|
164
|
+
} else if (typeof token.content === 'string') {
|
|
165
|
+
const leafType = token.type === 'prefix' && typeof token.alias === 'string'
|
|
166
|
+
? token.alias
|
|
167
|
+
: token.type
|
|
168
|
+
html += wrapToken(token.content, leafType)
|
|
169
|
+
} else if (Array.isArray(token.content)) {
|
|
170
|
+
html += tokensToLexicalHtml(token.content, token.type === 'unchanged' ? undefined : token.type)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return html
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function highlightToLexicalHtml(code, lang) {
|
|
177
|
+
// No grammar (unlabeled / unsupported language) → render as plaintext rather
|
|
178
|
+
// than forcing JavaScript, so a block we can't confidently classify isn't
|
|
179
|
+
// mis-colored as JS.
|
|
180
|
+
const grammar = lang ? Prism.languages[lang] : null
|
|
181
|
+
if (!grammar) return escapeHtml(code)
|
|
182
|
+
return tokensToLexicalHtml(Prism.tokenize(code, grammar), undefined)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-tokenize server-rendered creative description code blocks with Prism.
|
|
186
|
+
//
|
|
187
|
+
// Creative descriptions are rendered server-side by commonmarker. We disable its
|
|
188
|
+
// built-in syntect highlighter (which bakes a fixed dark theme inline), so the
|
|
189
|
+
// stored HTML arrives as plain `<pre lang="ruby"><code>raw source</code></pre>`.
|
|
190
|
+
// This pass re-tokenizes that source with the SAME Prism instance, language set,
|
|
191
|
+
// and `lexical-token-*` token classes the editor uses, so edit mode and rendered
|
|
192
|
+
// mode are colored token-for-token identically (not just the same palette) and
|
|
193
|
+
// follow the light/dark theme via the shared `--syntax-*` variables.
|
|
194
|
+
//
|
|
195
|
+
// Reading `textContent` (not innerHTML) means legacy descriptions whose stored
|
|
196
|
+
// HTML still carries baked-in inline-styled spans get re-highlighted too — no
|
|
197
|
+
// data migration needed. Idempotent via the `data-hljs-highlighted` marker.
|
|
198
|
+
export function highlightCodeBlocks(container) {
|
|
199
|
+
if (!container) return
|
|
200
|
+
const blocks = container.querySelectorAll('pre code:not([data-hljs-highlighted])')
|
|
201
|
+
blocks.forEach((code) => {
|
|
202
|
+
const pre = code.closest('pre')
|
|
203
|
+
let lang = sanitizeLang(pre && pre.getAttribute('lang'))
|
|
204
|
+
if (!lang) {
|
|
205
|
+
const match = /(?:^|\s)language-([\w-]+)/.exec(code.className || '')
|
|
206
|
+
if (match) lang = sanitizeLang(match[1])
|
|
207
|
+
}
|
|
208
|
+
// Resolve the language the same way the editor does. In the rendered view the
|
|
209
|
+
// language always comes from the stored source (a real fence / <pre lang>), so
|
|
210
|
+
// an explicit label is honored verbatim — including "javascript" — exactly as
|
|
211
|
+
// the editor honors an import-resolved language. Only genuinely unlabeled
|
|
212
|
+
// blocks are content-detected. This keeps edit and view in lock-step; without
|
|
213
|
+
// it the view would re-detect an explicit ```javascript to ruby while the
|
|
214
|
+
// editor honored javascript, and the two would disagree.
|
|
215
|
+
const resolved = lang ? normalizeFenceLang(lang) : detectCodeLanguage(code.textContent, '')
|
|
216
|
+
// Build the markup ourselves with escaped text and only `lexical-token-*`
|
|
217
|
+
// classes, then sanitize as defense-in-depth (DOMPurify keeps those spans
|
|
218
|
+
// via the class hook and neutralizes anything unexpected).
|
|
219
|
+
code.innerHTML = sanitize(highlightToLexicalHtml(code.textContent, resolved))
|
|
220
|
+
code.dataset.hljsHighlighted = 'true'
|
|
221
|
+
// Drop any baked-in inline background (e.g. syntect's dark `<pre style=…>`)
|
|
222
|
+
// so the theme-aware --color-code-bg from code_highlight.css wins.
|
|
223
|
+
if (pre) pre.removeAttribute('style')
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
140
227
|
// Lazy-load mermaid and render diagrams in a container
|
|
141
228
|
let mermaidReady = false
|
|
142
229
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify'
|
|
2
|
+
|
|
3
|
+
// Trusted YouTube embed origins. The server's `embed_youtube_iframe` helper
|
|
4
|
+
// (app/helpers/application_helper.rb) only ever emits `youtube.com/embed/...`
|
|
5
|
+
// iframes, so we mirror that here and refuse every other iframe source.
|
|
6
|
+
const YOUTUBE_EMBED_SRC =
|
|
7
|
+
/^https:\/\/(www\.)?(youtube\.com|youtube-nocookie\.com)\/embed\//i
|
|
8
|
+
|
|
9
|
+
// DOMPurify's default config strips <iframe> entirely, which silently removed
|
|
10
|
+
// the YouTube preview iframe the server had already generated — the description
|
|
11
|
+
// rendered blank. Re-allow iframes, but only from trusted YouTube embed origins
|
|
12
|
+
// so arbitrary iframe injection (clickjacking / XSS) stays blocked.
|
|
13
|
+
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
|
14
|
+
if (data.tagName !== 'iframe') return
|
|
15
|
+
const src = node.getAttribute && node.getAttribute('src')
|
|
16
|
+
if (!src || !YOUTUBE_EMBED_SRC.test(src)) {
|
|
17
|
+
node.parentNode && node.parentNode.removeChild(node)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Mirrors the iframe attributes in EMBED_ALLOWED_ATTRS server-side.
|
|
22
|
+
const PURIFY_CONFIG = {
|
|
23
|
+
ADD_TAGS: ['iframe'],
|
|
24
|
+
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder']
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Sanitize creative description HTML for client-side rendering while keeping the
|
|
28
|
+
// server-generated YouTube preview iframe intact.
|
|
29
|
+
export function sanitizeDescriptionHtml(html) {
|
|
30
|
+
return DOMPurify.sanitize(html ?? '', PURIFY_CONFIG)
|
|
31
|
+
}
|
|
@@ -161,3 +161,18 @@ export function addTableDownloadButtons(contentElement) {
|
|
|
161
161
|
wrapper.appendChild(table)
|
|
162
162
|
})
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wraps only the tables in a creative row's *display* areas
|
|
167
|
+
* (.creative-content / .creative-title-content). The inline edit form is
|
|
168
|
+
* appended into .creative-tree as a sibling of these areas, so scanning the
|
|
169
|
+
* whole row would move the live Lexical editor / markdown-preview <table>
|
|
170
|
+
* into a wrapper and corrupt in-progress table edits.
|
|
171
|
+
*/
|
|
172
|
+
export function addCreativeTableDownloadButtons(rowElement) {
|
|
173
|
+
if (!rowElement) return
|
|
174
|
+
|
|
175
|
+
rowElement
|
|
176
|
+
.querySelectorAll('.creative-content, .creative-title-content')
|
|
177
|
+
.forEach((area) => addTableDownloadButtons(area))
|
|
178
|
+
}
|