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
@@ -1,12 +1,15 @@
1
1
  import creativesApi from '../lib/api/creatives'
2
2
  import apiQueue from '../lib/api/queue_manager'
3
- import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $isRootOrShadowRoot } from 'lexical'
3
+ import { $getSelection } from 'lexical'
4
+ import { isSelectionAtDocumentStart, isSelectionAtDocumentEnd } from '../lib/lexical/selection_boundary'
4
5
  import { createInlineEditor } from './lexical_inline_editor'
5
6
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
6
7
  import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
7
8
  import { renderMarkdown } from '../lib/utils/markdown'
8
9
  import { reconcileMarkdownSource } from './markdown_source_reconcile'
9
10
  import { isHtmlEmpty } from './html_content_empty'
11
+ import { confirmDialog, alertDialog } from '../lib/utils/dialog'
12
+ import { serverErrorMessage } from '../lib/api/api_error'
10
13
  import yaml from 'js-yaml'
11
14
  // Import Stimulus application from the global window (set by host app)
12
15
  const application = window.Stimulus
@@ -57,11 +60,15 @@ export function initializeCreativeRowEditor() {
57
60
  // In a real app, use a toast notification. For now, alert is safe.
58
61
  // Suppress 404 errors for PATCH requests, as this likely means the item was deleted
59
62
  // and we don't need to alert the user about it.
60
- const is404 = error && error.toString().includes('404');
63
+ const is404 = (error && error.status === 404) || (error && error.toString().includes('404'));
61
64
  const isPatch = item && item.method === 'PATCH';
62
65
 
63
66
  if (!(is404 && isPatch)) {
64
- alert(`Failed to save changes. Please check your connection and try again.\nError: ${error}`);
67
+ // Prefer the server's own error message (e.g. "Description cannot be
68
+ // changed directly for GitHub synced content") and fall back to the
69
+ // generic copy only when the failure carries no usable payload.
70
+ const serverMessage = serverErrorMessage(error);
71
+ alertDialog(serverMessage || 'Failed to save changes. Please check your connection and try again.');
65
72
  }
66
73
 
67
74
  // If the failed item matches the current creative, mark it as dirty so it can be retried
@@ -112,6 +119,7 @@ export function initializeCreativeRowEditor() {
112
119
 
113
120
  // Markdown editor elements
114
121
  const contentTypeInput = document.getElementById('inline-content-type');
122
+ const markdownEditorInput = document.getElementById('inline-markdown-editor');
115
123
  const markdownSourceInput = document.getElementById('inline-markdown-source');
116
124
  const markdownWrapper = document.getElementById('markdown-editor-wrapper');
117
125
  const markdownTextarea = document.getElementById('markdown-editor-textarea');
@@ -258,6 +266,9 @@ export function initializeCreativeRowEditor() {
258
266
  if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
259
267
  setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
260
268
  }
269
+ if (Object.prototype.hasOwnProperty.call(data, 'markdown_editor')) {
270
+ setRowDatasetValue(row, 'markdownEditor', data.markdown_editor ?? '');
271
+ }
261
272
  if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
262
273
  if (data.has_children) {
263
274
  row.setAttribute('has-children', '');
@@ -298,6 +309,7 @@ export function initializeCreativeRowEditor() {
298
309
  parent_id: parentId,
299
310
  progress: Number.isNaN(progressValue) ? 0 : progressValue,
300
311
  content_type: row.dataset?.contentType || null,
312
+ markdown_editor: row.dataset?.markdownEditor || null,
301
313
  markdown_source: row.dataset?.markdownSource || null
302
314
  };
303
315
  }
@@ -309,6 +321,7 @@ export function initializeCreativeRowEditor() {
309
321
  function activateMarkdownMode(source) {
310
322
  markdownMode = true;
311
323
  if (contentTypeInput) contentTypeInput.value = 'markdown';
324
+ if (markdownEditorInput) markdownEditorInput.value = 'source';
312
325
  if (markdownTextarea) markdownTextarea.value = source || '';
313
326
  if (markdownWrapper) markdownWrapper.style.display = '';
314
327
  if (editorContainer) editorContainer.style.display = 'none';
@@ -323,6 +336,7 @@ export function initializeCreativeRowEditor() {
323
336
  function deactivateMarkdownMode() {
324
337
  markdownMode = false;
325
338
  if (contentTypeInput) contentTypeInput.value = 'html';
339
+ if (markdownEditorInput) markdownEditorInput.value = '';
326
340
  if (markdownSourceInput) markdownSourceInput.value = '';
327
341
  if (markdownWrapper) markdownWrapper.style.display = 'none';
328
342
  if (editorContainer) editorContainer.style.display = '';
@@ -349,20 +363,33 @@ export function initializeCreativeRowEditor() {
349
363
  const content = data.description_raw_html || data.description || '';
350
364
  descriptionInput.value = content;
351
365
 
352
- // Handle markdown vs rich text mode
366
+ // Markdown is the canonical storage format for BOTH editors now. Which
367
+ // surface opens is decided by the persisted editor preference: only
368
+ // explicitly rich-authored Markdown reopens in Lexical; "source" and
369
+ // legacy (no preference) Markdown reopen in the advanced textarea.
353
370
  const isMarkdown = data.content_type === 'markdown';
354
- if (isMarkdown) {
371
+ const useTextarea = isMarkdown && data.markdown_editor !== 'rich';
372
+ if (useTextarea) {
355
373
  activateMarkdownMode(data.markdown_source || '');
356
374
  // Also load Lexical with HTML for fallback/switching
357
375
  lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
358
376
  } else {
359
377
  deactivateMarkdownMode();
378
+ if (isMarkdown) {
379
+ // Rich-authored Markdown: prime the hidden fields so a no-edit save
380
+ // (move, progress toggle) preserves Markdown canonical instead of
381
+ // demoting back to HTML before the first Lexical change fires.
382
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
383
+ if (markdownSourceInput) markdownSourceInput.value = data.markdown_source || '';
384
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
385
+ }
360
386
  lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
361
387
  }
362
388
 
363
389
  pendingSave = false;
364
- // Track original content for dirty state detection
365
- originalContent = isMarkdown ? (data.markdown_source || '') : content;
390
+ // Dirty detection is HTML-based for the rich surface (compares the editor's
391
+ // HTML projection), and Markdown-source-based for the textarea surface.
392
+ originalContent = useTextarea ? (data.markdown_source || '') : content;
366
393
  isDirty = false;
367
394
  const progressNumber = Number(data.progress ?? 0);
368
395
  const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
@@ -384,7 +411,12 @@ export function initializeCreativeRowEditor() {
384
411
  const effectiveParent = parentInput.value;
385
412
  if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
386
413
  originalProgress = normalizedProgress;
387
- if (!isMarkdown) {
414
+ // Focus the Lexical editor whenever it is the active surface. Gating on
415
+ // `!isMarkdown` was correct when `content_type === 'markdown'` always meant
416
+ // the textarea surface, but rich-authored Markdown now reopens in Lexical
417
+ // (markdown_editor === 'rich'), so use `!useTextarea` to also focus it.
418
+ // The textarea surface focuses itself in activateMarkdownMode().
419
+ if (!useTextarea) {
388
420
  lexicalEditor.focus();
389
421
  }
390
422
  updateActionButtonStates();
@@ -1288,8 +1320,12 @@ export function initializeCreativeRowEditor() {
1288
1320
 
1289
1321
  // CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
1290
1322
  // to a different creative while we're waiting for uploads
1291
- const isMarkdownSave = markdownMode;
1292
- if (isMarkdownSave) syncMarkdownToForm();
1323
+ // Both editor surfaces persist Markdown now. The textarea surface
1324
+ // (markdownMode) syncs its value to the hidden fields here; the rich
1325
+ // surface already kept them current via onLexicalChange/applyCreativeData.
1326
+ if (markdownMode) syncMarkdownToForm();
1327
+ const capturedContentType = contentTypeInput ? contentTypeInput.value : 'html';
1328
+ const isMarkdownSave = capturedContentType === 'markdown';
1293
1329
  let currentContent = descriptionInput.value;
1294
1330
  let currentProgress = readProgressValue();
1295
1331
  let shouldPersistProgress = progressValueChanged();
@@ -1297,8 +1333,8 @@ export function initializeCreativeRowEditor() {
1297
1333
  const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
1298
1334
  const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
1299
1335
  const startCreativeId = creativeId;
1300
- let capturedMarkdownSource = isMarkdownSave ? (markdownTextarea?.value || '') : '';
1301
- const capturedContentType = isMarkdownSave ? 'markdown' : 'html';
1336
+ let capturedMarkdownSource = isMarkdownSave ? (markdownSourceInput ? markdownSourceInput.value : '') : '';
1337
+ const capturedMarkdownEditor = markdownEditorInput ? markdownEditorInput.value : '';
1302
1338
 
1303
1339
  // Prevent saving empty content, matching saveForm behavior
1304
1340
  // This avoids overwriting existing descriptions with empty strings during quick navigation
@@ -1320,9 +1356,12 @@ export function initializeCreativeRowEditor() {
1320
1356
  // so we must re-sync and re-capture the latest textarea value too — otherwise edits
1321
1357
  // made during the upload wait get overwritten by the stale pre-wait source.
1322
1358
  if (form.dataset.creativeId === startCreativeId) {
1323
- if (isMarkdownSave) {
1359
+ if (markdownMode) {
1324
1360
  syncMarkdownToForm();
1325
- capturedMarkdownSource = markdownTextarea?.value || '';
1361
+ capturedMarkdownSource = markdownSourceInput ? markdownSourceInput.value : '';
1362
+ } else if (isMarkdownSave && markdownSourceInput) {
1363
+ // Rich surface: re-capture any Markdown produced by edits during the wait.
1364
+ capturedMarkdownSource = markdownSourceInput.value;
1326
1365
  }
1327
1366
  currentContent = descriptionInput.value;
1328
1367
  currentProgress = readProgressValue();
@@ -1338,6 +1377,9 @@ export function initializeCreativeRowEditor() {
1338
1377
  };
1339
1378
  if (isMarkdownSave) {
1340
1379
  body['creative[markdown_source]'] = capturedMarkdownSource;
1380
+ if (capturedMarkdownEditor) {
1381
+ body['creative[markdown_editor]'] = capturedMarkdownEditor;
1382
+ }
1341
1383
  }
1342
1384
 
1343
1385
  if (shouldPersistProgress) {
@@ -1368,6 +1410,10 @@ export function initializeCreativeRowEditor() {
1368
1410
  }
1369
1411
  row.dataset.contentType = capturedContentType;
1370
1412
  row.dataset.markdownSource = isMarkdownSave ? capturedMarkdownSource : '';
1413
+ // Persist which surface authored this save so a row re-opened from this
1414
+ // cached payload (before any full GET refresh) reopens in the right
1415
+ // editor — without it, rich-authored Markdown falls back to the textarea.
1416
+ row.dataset.markdownEditor = isMarkdownSave ? capturedMarkdownEditor : '';
1371
1417
  if (currentParentId) {
1372
1418
  tree.dataset.parentId = currentParentId;
1373
1419
  row.parentId = currentParentId;
@@ -1929,10 +1975,18 @@ export function initializeCreativeRowEditor() {
1929
1975
  saveTimer = setTimeout(saveForm, 5000);
1930
1976
  }
1931
1977
 
1932
- function onLexicalChange(html) {
1933
- if (markdownMode) return; // Ignore Lexical changes in markdown mode
1978
+ function onLexicalChange(payload) {
1979
+ if (markdownMode) return; // Ignore Lexical changes when the textarea is active
1980
+ const html = (payload && payload.html) || '';
1981
+ const markdown = (payload && payload.markdown) || '';
1934
1982
  descriptionInput.value = html;
1935
- // Mark as dirty if content changed from original
1983
+ // The rich editor is now a Markdown-canonical surface: persist the Markdown
1984
+ // projection (text color/background as normalized <span> fragments) and
1985
+ // record that the rich surface authored it, so it reopens in Lexical.
1986
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
1987
+ if (markdownSourceInput) markdownSourceInput.value = markdown;
1988
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
1989
+ // Mark as dirty if the HTML projection changed from original
1936
1990
  isDirty = (html !== originalContent);
1937
1991
  scheduleSave();
1938
1992
  }
@@ -1992,9 +2046,11 @@ export function initializeCreativeRowEditor() {
1992
2046
  let atEnd = false;
1993
2047
  editorInstance.getEditorState().read(() => {
1994
2048
  const selection = $getSelection();
1995
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1996
- const [start, end] = $getCharacterOffsets(selection);
1997
- atStart = start === 0 && end === 0;
2049
+ // atStart must reflect the start of the whole document, not just offset 0
2050
+ // of the current node — otherwise the start of a second paragraph (e.g.
2051
+ // right after pressing Enter) is mistaken for the top and ArrowUp jumps
2052
+ // to the row above instead of moving the cursor up. See isSelectionAtDocumentStart.
2053
+ atStart = isSelectionAtDocumentStart(selection);
1998
2054
  atEnd = isSelectionAtDocumentEnd(selection);
1999
2055
  });
2000
2056
 
@@ -2014,32 +2070,6 @@ export function initializeCreativeRowEditor() {
2014
2070
  }
2015
2071
  }
2016
2072
 
2017
- function isSelectionAtDocumentEnd(selection) {
2018
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
2019
-
2020
- const focus = selection.focus;
2021
- let node = focus.getNode();
2022
- if (!node) return false;
2023
-
2024
- const offset = focus.offset;
2025
- if ($isTextNode(node)) {
2026
- if (offset !== node.getTextContentSize()) return false;
2027
- } else if (typeof node.getChildrenSize === 'function') {
2028
- if (offset !== node.getChildrenSize()) return false;
2029
- } else {
2030
- // Fallback for nodes without children size (e.g., line breaks)
2031
- const textSize = node.getTextContentSize?.() ?? 0;
2032
- if (offset !== textSize) return false;
2033
- }
2034
-
2035
- while (node && !$isRootOrShadowRoot(node)) {
2036
- if (node.getNextSibling()) return false;
2037
- node = node.getParent();
2038
- }
2039
-
2040
- return !!node && $isRootOrShadowRoot(node);
2041
- }
2042
-
2043
2073
  if (progressInput) {
2044
2074
  progressInput.addEventListener('change', function () {
2045
2075
  if (progressValue) {
@@ -2052,7 +2082,7 @@ export function initializeCreativeRowEditor() {
2052
2082
  completionCascadePending = true;
2053
2083
  const alertMessage = progressInput.dataset.childrenAlertMessage;
2054
2084
  if (alertMessage) {
2055
- alert(alertMessage);
2085
+ alertDialog(alertMessage);
2056
2086
  }
2057
2087
  }
2058
2088
  updateProgressInputAvailability(readProgressValue());
@@ -2138,14 +2168,14 @@ export function initializeCreativeRowEditor() {
2138
2168
  }
2139
2169
 
2140
2170
  if (archiveBtn) {
2141
- archiveBtn.addEventListener('click', function () {
2171
+ archiveBtn.addEventListener('click', async function () {
2142
2172
  const creativeId = form.dataset.creativeId;
2143
2173
  if (!creativeId) return;
2144
2174
  const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`);
2145
2175
  const isArchived = row?.hasAttribute('archived');
2146
2176
  const confirmMsg = isArchived ? archiveBtn.dataset.restoreConfirm : archiveBtn.dataset.confirm;
2147
2177
 
2148
- if (confirm(confirmMsg)) {
2178
+ if (await confirmDialog(confirmMsg)) {
2149
2179
  const apiCall = isArchived ? creativesApi.unarchive(creativeId) : creativesApi.archive(creativeId);
2150
2180
  apiCall.then(res => {
2151
2181
  if (res.ok) {
@@ -2172,14 +2202,14 @@ export function initializeCreativeRowEditor() {
2172
2202
  }
2173
2203
 
2174
2204
  if (deleteBtn) {
2175
- deleteBtn.addEventListener('click', function () {
2176
- if (confirm(deleteBtn.dataset.confirm)) deleteCurrent(false);
2205
+ deleteBtn.addEventListener('click', async function () {
2206
+ if (await confirmDialog(deleteBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
2177
2207
  });
2178
2208
  }
2179
2209
 
2180
2210
  if (deleteWithChildrenBtn) {
2181
- deleteWithChildrenBtn.addEventListener('click', function () {
2182
- if (confirm(deleteWithChildrenBtn.dataset.confirm)) deleteCurrent(true);
2211
+ deleteWithChildrenBtn.addEventListener('click', async function () {
2212
+ if (await confirmDialog(deleteWithChildrenBtn.dataset.confirm, { danger: true })) deleteCurrent(true);
2183
2213
  });
2184
2214
  }
2185
2215
 
@@ -2215,17 +2245,17 @@ export function initializeCreativeRowEditor() {
2215
2245
  }
2216
2246
 
2217
2247
  if (unlinkBtn) {
2218
- unlinkBtn.addEventListener('click', function () {
2219
- if (confirm(unlinkBtn.dataset.confirm)) deleteCurrent(false);
2248
+ unlinkBtn.addEventListener('click', async function () {
2249
+ if (await confirmDialog(unlinkBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
2220
2250
  });
2221
2251
  }
2222
2252
 
2223
2253
  if (unconvertBtn) {
2224
- unconvertBtn.addEventListener('click', function () {
2254
+ unconvertBtn.addEventListener('click', async function () {
2225
2255
  const creativeId = form.dataset.creativeId;
2226
2256
  if (!creativeId) return;
2227
2257
  const confirmText = unconvertBtn.dataset.confirm;
2228
- if (confirmText && !confirm(confirmText)) return;
2258
+ if (confirmText && !(await confirmDialog(confirmText))) return;
2229
2259
  const errorMessage = unconvertBtn.dataset.error || 'Failed to unconvert.';
2230
2260
  unconvertBtn.disabled = true;
2231
2261
  saveForm()
@@ -2235,7 +2265,7 @@ export function initializeCreativeRowEditor() {
2235
2265
  .json()
2236
2266
  .catch(function () { return {}; })
2237
2267
  .then(function (data) {
2238
- alert(data && data.error ? data.error : errorMessage);
2268
+ alertDialog(data && data.error ? data.error : errorMessage);
2239
2269
  const error = new Error('Save failed');
2240
2270
  error._handled = true;
2241
2271
  throw error;
@@ -2252,12 +2282,12 @@ export function initializeCreativeRowEditor() {
2252
2282
  .json()
2253
2283
  .catch(function () { return {}; })
2254
2284
  .then(function (data) {
2255
- alert(data && data.error ? data.error : errorMessage);
2285
+ alertDialog(data && data.error ? data.error : errorMessage);
2256
2286
  });
2257
2287
  })
2258
2288
  .catch(function (error) {
2259
2289
  if (error && error._handled) return;
2260
- alert(errorMessage);
2290
+ alertDialog(errorMessage);
2261
2291
  })
2262
2292
  .finally(function () {
2263
2293
  unconvertBtn.disabled = false;
@@ -2276,7 +2306,7 @@ export function initializeCreativeRowEditor() {
2276
2306
  })
2277
2307
  .catch(function (error) {
2278
2308
  console.error('Failed to load metadata:', error);
2279
- alert('Failed to load metadata');
2309
+ alertDialog('Failed to load metadata');
2280
2310
  });
2281
2311
  }
2282
2312
 
@@ -2321,17 +2351,17 @@ export function initializeCreativeRowEditor() {
2321
2351
  metadataPopup.style.display = 'none';
2322
2352
  } else {
2323
2353
  return response.json().then(function (data) {
2324
- alert('Failed to save metadata: ' + (data.error || 'Unknown error'));
2354
+ alertDialog('Failed to save metadata: ' + (data.error || 'Unknown error'));
2325
2355
  });
2326
2356
  }
2327
2357
  })
2328
2358
  .catch(function (error) {
2329
2359
  console.error('Failed to save metadata:', error);
2330
- alert('Failed to save metadata');
2360
+ alertDialog('Failed to save metadata');
2331
2361
  });
2332
2362
  } catch (error) {
2333
2363
  console.error('YAML parse error:', error);
2334
- alert('Invalid YAML format: ' + error.message);
2364
+ alertDialog('Invalid YAML format: ' + error.message);
2335
2365
  }
2336
2366
  });
2337
2367
  }
@@ -2339,27 +2369,37 @@ export function initializeCreativeRowEditor() {
2339
2369
 
2340
2370
  // Markdown toggle button
2341
2371
  if (toggleMarkdownBtn) {
2342
- toggleMarkdownBtn.addEventListener('click', function () {
2372
+ toggleMarkdownBtn.addEventListener('click', async function () {
2343
2373
  if (markdownMode) {
2344
- // Switching from Markdown → Rich Text
2374
+ // Switching from Markdown (advanced textarea) → Rich Text. The Markdown
2375
+ // stays canonical; we only flip the authoring surface to rich so it
2376
+ // reopens in Lexical, and render it to HTML for the editor view.
2345
2377
  const confirmMsg = toggleMarkdownBtn.dataset.confirmToRichtext;
2346
- if (confirmMsg && !confirm(confirmMsg)) return;
2378
+ if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
2347
2379
  const md = markdownTextarea?.value || '';
2348
2380
  const html = md ? renderMarkdown(md) : '';
2349
2381
  deactivateMarkdownMode();
2350
2382
  descriptionInput.value = html;
2383
+ if (md) {
2384
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
2385
+ if (markdownSourceInput) markdownSourceInput.value = md;
2386
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
2387
+ }
2351
2388
  lexicalEditor.load(html, `creative-switch-${Date.now()}`);
2352
2389
  lexicalEditor.focus();
2353
2390
  isDirty = true;
2354
2391
  scheduleSave();
2355
2392
  } else {
2356
- // Switching from Rich Text → Markdown
2393
+ // Switching from Rich Text → Markdown (advanced textarea). Preserve the
2394
+ // content by seeding the textarea with the rich surface's current
2395
+ // Markdown projection instead of discarding it.
2357
2396
  const currentHtml = descriptionInput.value || '';
2358
2397
  if (!isHtmlEmpty(currentHtml)) {
2359
2398
  const confirmMsg = toggleMarkdownBtn.dataset.confirmToMarkdown;
2360
- if (confirmMsg && !confirm(confirmMsg)) return;
2399
+ if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
2361
2400
  }
2362
- activateMarkdownMode('');
2401
+ const existingMarkdown = markdownSourceInput ? markdownSourceInput.value : '';
2402
+ activateMarkdownMode(existingMarkdown);
2363
2403
  isDirty = true;
2364
2404
  scheduleSave();
2365
2405
  }
@@ -1,3 +1,4 @@
1
+ import { alertDialog } from '../lib/utils/dialog';
1
2
 
2
3
  if (!window.isExportMarkdownEnabled) {
3
4
  window.isExportMarkdownEnabled = true;
@@ -27,7 +28,7 @@ if (!window.isExportMarkdownEnabled) {
27
28
  URL.revokeObjectURL(url);
28
29
  }, 0);
29
30
  })
30
- .catch(err => alert(err));
31
+ .catch(err => alertDialog(err));
31
32
  });
32
33
  }
33
34
  });
@@ -52,7 +52,8 @@ export function createInlineEditor(container, {
52
52
  suppressNextChange = false
53
53
  return
54
54
  }
55
- currentHtml = value
55
+ // value is { html, markdown }; keep currentHtml for the render fallback.
56
+ currentHtml = value?.html ?? ""
56
57
  onChange?.(value)
57
58
  }}
58
59
  onReady={(api) => {
@@ -1,5 +1,7 @@
1
1
  import { createSubscription } from '../services/cable'
2
- import DOMPurify from 'dompurify'
2
+ import { highlightCodeBlocks } from '../lib/utils/markdown'
3
+ import { addTableDownloadButtons } from '../lib/utils/table_download'
4
+ import { sanitizeDescriptionHtml } from '../lib/utils/sanitize_description'
3
5
 
4
6
  document.addEventListener('DOMContentLoaded', function() {
5
7
  var container = document.getElementById('slide-view');
@@ -24,6 +26,10 @@ document.addEventListener('DOMContentLoaded', function() {
24
26
  load(index, false);
25
27
  } else {
26
28
  updateUrl(index);
29
+ // Initial slide is server-rendered (static ERB): re-tokenize its code blocks
30
+ // and attach table download buttons, same as the JS-loaded slides below.
31
+ highlightCodeBlocks(contentEl);
32
+ addTableDownloadButtons(contentEl);
27
33
  }
28
34
 
29
35
  function updateUrl(idx) {
@@ -60,8 +66,11 @@ document.addEventListener('DOMContentLoaded', function() {
60
66
  }
61
67
  contentEl.innerHTML = '';
62
68
  var el = document.createElement(tag);
63
- el.innerHTML = DOMPurify.sanitize(data.description);
69
+ el.className = 'creative-content';
70
+ el.innerHTML = sanitizeDescriptionHtml(data.description_embedded_html || data.description);
64
71
  contentEl.appendChild(el);
72
+ highlightCodeBlocks(el);
73
+ addTableDownloadButtons(el);
65
74
  if (captionEl) {
66
75
  captionEl.textContent = data.prompt || '';
67
76
  }