collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -1,20 +1,50 @@
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 { CreativeSaveQueue } from './creative_save_queue'
12
+ import { confirmDialog, alertDialog } from '../lib/utils/dialog'
13
+ import { serverErrorMessage } from '../lib/api/api_error'
10
14
  import yaml from 'js-yaml'
15
+ import {
16
+ treeRowElement,
17
+ hasDatasetValue,
18
+ isMarkdownEmpty,
19
+ readRowLevel,
20
+ editorPaddingForLevel,
21
+ } from './creative_row_editor_helpers'
22
+ import {
23
+ creativeTreeElement,
24
+ creativeIdFrom,
25
+ siblingTreeRow,
26
+ siblingOrderingForRow,
27
+ treeContainerElement,
28
+ nodeAfterTreeBlock,
29
+ normalizeRowNode,
30
+ childrenContainerForTree,
31
+ ensureChildrenContainer,
32
+ expandChildrenContainer,
33
+ moveTreeBlock,
34
+ listAllTreeNodes,
35
+ findPreviousTree,
36
+ getTreeLevel,
37
+ updateTreeLevels,
38
+ setTreeLevel,
39
+ removeTreeElement,
40
+ } from './creative_tree_dom'
41
+ import {
42
+ updateRowFromData,
43
+ inlinePayloadFromTree,
44
+ } from './creative_inline_payload'
11
45
  // Import Stimulus application from the global window (set by host app)
12
46
  const application = window.Stimulus
13
47
 
14
- const BULLET_STARTING_LEVEL = 3;
15
- const HEADING_INDENT_STEP_EM = 0.4;
16
- const BULLET_INDENT_STEP_PX = 30;
17
-
18
48
  let initialized = false;
19
49
  let creativeEditClickHandler = null;
20
50
  let addCreativeShortcutHandler = null;
@@ -57,24 +87,45 @@ export function initializeCreativeRowEditor() {
57
87
  // In a real app, use a toast notification. For now, alert is safe.
58
88
  // Suppress 404 errors for PATCH requests, as this likely means the item was deleted
59
89
  // and we don't need to alert the user about it.
60
- const is404 = error && error.toString().includes('404');
90
+ const is404 = (error && error.status === 404) || (error && error.toString().includes('404'));
61
91
  const isPatch = item && item.method === 'PATCH';
62
92
 
63
93
  if (!(is404 && isPatch)) {
64
- alert(`Failed to save changes. Please check your connection and try again.\nError: ${error}`);
65
- }
66
-
67
- // If the failed item matches the current creative, mark it as dirty so it can be retried
68
- if (form.dataset.creativeId && item.path.includes(form.dataset.creativeId)) {
94
+ // Prefer the server's own error message (e.g. "Description cannot be
95
+ // changed directly for GitHub synced content") and fall back to the
96
+ // generic copy only when the failure carries no usable payload.
97
+ const serverMessage = serverErrorMessage(error);
98
+ alertDialog(serverMessage || 'Failed to save changes. Please check your connection and try again.');
99
+ }
100
+
101
+ // If the failed item matches the current creative, mark it as dirty so it can be retried.
102
+ // Match the id exactly — a substring test (e.g. path.includes("23")) also matches
103
+ // "/creatives/123", flagging the wrong row's toolbar as failed.
104
+ const failedCreativeId = (item.path.match(/\/creatives\/(\d+)/) || [])[1];
105
+ if (form.dataset.creativeId && failedCreativeId === form.dataset.creativeId) {
69
106
  console.log('Restoring dirty state for current creative');
70
107
  isDirty = true;
71
108
  pendingSave = true;
109
+ setSaveStatus('error');
72
110
  updateActionButtonStates();
73
111
  }
74
112
  });
75
113
 
76
114
  // ... rest of initialization
77
115
 
116
+ // Reflect the inline editor's save lifecycle in the toolbar row.
117
+ // Plain JS controller can't call the i18n `t()` helper, so the localized
118
+ // strings are carried on the span's data-* attributes (set in the ERB).
119
+ // state: 'pending' | 'saving' | 'saved' | 'error' | '' (cleared).
120
+ // 'pending' = dirty, waiting out the debounce; 'saving' = request in flight.
121
+ function setSaveStatus(state) {
122
+ const el = document.getElementById('inline-save-status');
123
+ if (!el) return;
124
+ const label = state ? el.dataset[`label${state.charAt(0).toUpperCase()}${state.slice(1)}`] : '';
125
+ el.textContent = label || '';
126
+ el.dataset.state = state || '';
127
+ }
128
+
78
129
  const form = document.getElementById('inline-edit-form-element');
79
130
  const descriptionInput = document.getElementById('inline-creative-description');
80
131
  const editorContainer = template.querySelector('[data-lexical-editor-root]');
@@ -112,6 +163,7 @@ export function initializeCreativeRowEditor() {
112
163
 
113
164
  // Markdown editor elements
114
165
  const contentTypeInput = document.getElementById('inline-content-type');
166
+ const markdownEditorInput = document.getElementById('inline-markdown-editor');
115
167
  const markdownSourceInput = document.getElementById('inline-markdown-source');
116
168
  const markdownWrapper = document.getElementById('markdown-editor-wrapper');
117
169
  const markdownTextarea = document.getElementById('markdown-editor-textarea');
@@ -149,10 +201,12 @@ export function initializeCreativeRowEditor() {
149
201
 
150
202
  let currentTree = null;
151
203
  let currentRowElement = null;
152
- let saveTimer = null;
204
+ // Owns the autosave request lifecycle: the debounce timer and the
205
+ // single-flight guard around the in-flight request. `pendingSave` /
206
+ // `isDirty` remain the editor's dirty-tracking flags (what changed), while
207
+ // the queue governs when the request runs. See creative_save_queue.js.
208
+ const saveQueue = new CreativeSaveQueue();
153
209
  let pendingSave = false;
154
- let saving = false;
155
- let savePromise = Promise.resolve();
156
210
  let uploadsPending = false;
157
211
  let uploadCompletionPromise = null;
158
212
  let resolveUploadCompletion = null;
@@ -211,104 +265,16 @@ export function initializeCreativeRowEditor() {
211
265
  }
212
266
  }
213
267
 
214
- function treeRowElement(node) {
215
- return node && node.closest ? node.closest('creative-tree-row') : null;
216
- }
217
-
218
268
  function currentRowHasChildren() {
219
269
  const row = currentRowElement || (currentTree ? treeRowElement(currentTree) : null);
220
270
  if (!row) return false;
221
271
  return !!(row.hasChildren || row.getAttribute?.('has-children'));
222
272
  }
223
273
 
224
- function hasDatasetValue(element, key) {
225
- if (!element || !element.dataset) return false;
226
- return Object.prototype.hasOwnProperty.call(element.dataset, key);
227
- }
228
-
229
- function setRowDatasetValue(row, key, value) {
230
- if (!row || !row.dataset) return;
231
- if (value === undefined || value === null) {
232
- delete row.dataset[key];
233
- } else {
234
- row.dataset[key] = String(value);
235
- }
236
- }
237
-
238
- function updateRowFromData(row, data) {
239
- if (!row || !data) return;
240
- const descriptionHtml = data.description || '';
241
- const rawHtml = data.description_raw_html || descriptionHtml;
242
- row.descriptionHtml = descriptionHtml;
243
- setRowDatasetValue(row, 'descriptionHtml', descriptionHtml);
244
- setRowDatasetValue(row, 'descriptionRawHtml', rawHtml);
245
- if (data.progress_html != null) {
246
- row.progressHtml = data.progress_html;
247
- setRowDatasetValue(row, 'progressHtml', data.progress_html);
248
- }
249
- if (Object.prototype.hasOwnProperty.call(data, 'progress')) {
250
- setRowDatasetValue(row, 'progressValue', data.progress ?? '');
251
- }
252
- if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
253
- setRowDatasetValue(row, 'originId', data.origin_id ?? '');
254
- }
255
- if (Object.prototype.hasOwnProperty.call(data, 'content_type')) {
256
- setRowDatasetValue(row, 'contentType', data.content_type ?? '');
257
- }
258
- if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
259
- setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
260
- }
261
- if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
262
- if (data.has_children) {
263
- row.setAttribute('has-children', '');
264
- row.hasChildren = true;
265
- } else {
266
- row.removeAttribute('has-children');
267
- row.hasChildren = false;
268
- }
269
- }
270
- if (typeof row.requestUpdate === 'function') {
271
- row.requestUpdate();
272
- }
273
- }
274
-
275
- function inlinePayloadFromTree(tree) {
276
- if (!tree) return null;
277
- const row = treeRowElement(tree);
278
- if (!row) return null;
279
-
280
- // Relax validation - allow loading with partial data for instant UI
281
- const hasDescription = hasDatasetValue(row, 'descriptionRawHtml') || hasDatasetValue(row, 'descriptionHtml');
282
- const hasProgress = hasDatasetValue(row, 'progressValue');
283
-
284
- // Only require ID to be present
285
- const id = tree.dataset?.id;
286
- if (!id) return null;
287
-
288
- const rawHtml = hasDatasetValue(row, 'descriptionRawHtml') ? row.dataset.descriptionRawHtml : row.dataset.descriptionHtml || '';
289
- const description = row.dataset.descriptionHtml || rawHtml || '';
290
- const progressValue = hasProgress ? Number(row.dataset.progressValue ?? 0) : 0;
291
- const parentId = tree.dataset?.parentId || '';
292
-
293
- return {
294
- id: id,
295
- description,
296
- description_raw_html: rawHtml,
297
- origin_id: row.dataset?.originId || '',
298
- parent_id: parentId,
299
- progress: Number.isNaN(progressValue) ? 0 : progressValue,
300
- content_type: row.dataset?.contentType || null,
301
- markdown_source: row.dataset?.markdownSource || null
302
- };
303
- }
304
-
305
- function isMarkdownEmpty(md) {
306
- return !md || md.trim().length === 0;
307
- }
308
-
309
274
  function activateMarkdownMode(source) {
310
275
  markdownMode = true;
311
276
  if (contentTypeInput) contentTypeInput.value = 'markdown';
277
+ if (markdownEditorInput) markdownEditorInput.value = 'source';
312
278
  if (markdownTextarea) markdownTextarea.value = source || '';
313
279
  if (markdownWrapper) markdownWrapper.style.display = '';
314
280
  if (editorContainer) editorContainer.style.display = 'none';
@@ -323,6 +289,7 @@ export function initializeCreativeRowEditor() {
323
289
  function deactivateMarkdownMode() {
324
290
  markdownMode = false;
325
291
  if (contentTypeInput) contentTypeInput.value = 'html';
292
+ if (markdownEditorInput) markdownEditorInput.value = '';
326
293
  if (markdownSourceInput) markdownSourceInput.value = '';
327
294
  if (markdownWrapper) markdownWrapper.style.display = 'none';
328
295
  if (editorContainer) editorContainer.style.display = '';
@@ -349,26 +316,42 @@ export function initializeCreativeRowEditor() {
349
316
  const content = data.description_raw_html || data.description || '';
350
317
  descriptionInput.value = content;
351
318
 
352
- // Handle markdown vs rich text mode
319
+ // Markdown is the canonical storage format for BOTH editors now. Which
320
+ // surface opens is decided by the persisted editor preference: only
321
+ // explicitly rich-authored Markdown reopens in Lexical; "source" and
322
+ // legacy (no preference) Markdown reopen in the advanced textarea.
353
323
  const isMarkdown = data.content_type === 'markdown';
354
- if (isMarkdown) {
324
+ const useTextarea = isMarkdown && data.markdown_editor !== 'rich';
325
+ if (useTextarea) {
355
326
  activateMarkdownMode(data.markdown_source || '');
356
327
  // Also load Lexical with HTML for fallback/switching
357
328
  lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
358
329
  } else {
359
330
  deactivateMarkdownMode();
331
+ if (isMarkdown) {
332
+ // Rich-authored Markdown: prime the hidden fields so a no-edit save
333
+ // (move, progress toggle) preserves Markdown canonical instead of
334
+ // demoting back to HTML before the first Lexical change fires.
335
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
336
+ if (markdownSourceInput) markdownSourceInput.value = data.markdown_source || '';
337
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
338
+ }
360
339
  lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
361
340
  }
362
341
 
363
342
  pendingSave = false;
364
- // Track original content for dirty state detection
365
- originalContent = isMarkdown ? (data.markdown_source || '') : content;
343
+ // Dirty detection is HTML-based for the rich surface (compares the editor's
344
+ // HTML projection), and Markdown-source-based for the textarea surface.
345
+ originalContent = useTextarea ? (data.markdown_source || '') : content;
366
346
  isDirty = false;
347
+ setSaveStatus('');
367
348
  const progressNumber = Number(data.progress ?? 0);
368
349
  const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
369
350
  setProgressState(normalizedProgress);
370
351
  updateProgressInputAvailability(normalizedProgress);
371
352
  completionCascadePending = false;
353
+ // parentId convention: `data-parent-id` is always present ("" === root),
354
+ // so this DOM fallback is unambiguous when server data lacks parent_id.
372
355
  const fallbackParent = tree?.dataset?.parentId || '';
373
356
  parentInput.value = data.parent_id ?? fallbackParent ?? '';
374
357
  beforeInput.value = '';
@@ -384,7 +367,12 @@ export function initializeCreativeRowEditor() {
384
367
  const effectiveParent = parentInput.value;
385
368
  if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
386
369
  originalProgress = normalizedProgress;
387
- if (!isMarkdown) {
370
+ // Focus the Lexical editor whenever it is the active surface. Gating on
371
+ // `!isMarkdown` was correct when `content_type === 'markdown'` always meant
372
+ // the textarea surface, but rich-authored Markdown now reopens in Lexical
373
+ // (markdown_editor === 'rich'), so use `!useTextarea` to also focus it.
374
+ // The textarea surface focuses itself in activateMarkdownMode().
375
+ if (!useTextarea) {
388
376
  lexicalEditor.focus();
389
377
  }
390
378
  updateActionButtonStates();
@@ -400,219 +388,6 @@ export function initializeCreativeRowEditor() {
400
388
  }
401
389
  }
402
390
 
403
- function siblingTreeRow(row, direction) {
404
- if (!row) return null;
405
- const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
406
- let node = row[step];
407
- while (node) {
408
- if (node.nodeType === Node.TEXT_NODE) {
409
- node = node[step];
410
- continue;
411
- }
412
- if (node.matches?.('creative-tree-row')) return node;
413
- if (node.classList?.contains?.('creative-children')) {
414
- node = node[step];
415
- continue;
416
- }
417
- node = node[step];
418
- }
419
- return null;
420
- }
421
-
422
- function siblingOrderingForRow(row) {
423
- const beforeRow = siblingTreeRow(row, 'next');
424
- const afterRow = siblingTreeRow(row, 'previous');
425
- return {
426
- beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
427
- afterId: afterRow ? creativeIdFrom(afterRow) : ''
428
- };
429
- }
430
-
431
- function treeContainerElement(tree) {
432
- if (!tree) return null;
433
- const row = treeRowElement(tree);
434
- if (row && row.parentNode) return row.parentNode;
435
- return tree.parentNode;
436
- }
437
-
438
- function nodeAfterTreeBlock(tree) {
439
- if (!tree) return null;
440
- const row = treeRowElement(tree);
441
- if (!row) return tree.nextSibling;
442
- let node = row.nextSibling;
443
- while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
444
- const treeId = tree.dataset?.id;
445
- if (treeId) {
446
- const childrenContainer = document.getElementById(`creative-children-${treeId}`);
447
- if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
448
- node = childrenContainer.nextSibling;
449
- while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
450
- }
451
- }
452
- return node;
453
- }
454
-
455
- function normalizeRowNode(node) {
456
- if (!node) return null;
457
- if (node.matches && node.matches('creative-tree-row')) return node;
458
- if (node.classList && node.classList.contains('creative-tree')) {
459
- const row = treeRowElement(node);
460
- return row || node;
461
- }
462
- return node;
463
- }
464
-
465
- function childrenContainerForTree(tree) {
466
- if (!tree) return null;
467
- const treeId = tree.dataset?.id;
468
- if (treeId) {
469
- const byId = document.getElementById(`creative-children-${treeId}`);
470
- if (byId) return byId;
471
- }
472
- if (tree.children && tree.children.length > 0) {
473
- for (const child of tree.children) {
474
- if (child && child.classList && child.classList.contains('creative-children')) {
475
- return child;
476
- }
477
- }
478
- }
479
- const row = treeRowElement(tree);
480
- if (row) {
481
- let sibling = row.nextElementSibling;
482
- while (sibling) {
483
- if (sibling.matches?.('creative-tree-row')) break;
484
- if (sibling.classList?.contains('creative-children')) return sibling;
485
- sibling = sibling.nextElementSibling;
486
- }
487
- }
488
- return null;
489
- }
490
-
491
- function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
492
- const params = new URLSearchParams();
493
- params.set('level', String(childLevel));
494
- params.set('select_mode', selectMode ? '1' : '0');
495
- return `/creatives/${parentId}/children?${params.toString()}`;
496
- }
497
-
498
- function ensureChildrenContainer(tree) {
499
- if (!tree) return null;
500
- let container = childrenContainerForTree(tree);
501
- if (container) return container;
502
- const parentId = tree.dataset?.id;
503
- if (!parentId) return null;
504
- container = document.createElement('div');
505
- container.className = 'creative-children';
506
- container.id = `creative-children-${parentId}`;
507
- const parentRow = treeRowElement(tree);
508
- const parentLevel = readRowLevel(parentRow) || 1;
509
- const childLevel = parentLevel + 1;
510
- const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
511
- container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
512
- container.dataset.expanded = 'true';
513
- if (container.dataset.loaded) delete container.dataset.loaded;
514
- const row = treeRowElement(tree);
515
- const parentContainer = row?.parentNode || tree.parentNode;
516
- if (parentContainer) {
517
- const afterRow = row?.nextSibling;
518
- if (afterRow) {
519
- parentContainer.insertBefore(container, afterRow);
520
- } else {
521
- parentContainer.appendChild(container);
522
- }
523
- } else {
524
- tree.appendChild(container);
525
- }
526
- return container;
527
- }
528
-
529
- function expandChildrenContainer(container) {
530
- if (!container) return;
531
- container.style.display = '';
532
- if (container.dataset) {
533
- container.dataset.expanded = 'true';
534
- }
535
- }
536
-
537
- function moveTreeBlock(tree, targetContainer, referenceNode = null) {
538
- if (!tree || !targetContainer) return;
539
- const row = treeRowElement(tree);
540
- if (!row) return;
541
- const nodesToMove = [row];
542
- const childContainer = childrenContainerForTree(tree);
543
- if (childContainer) nodesToMove.push(childContainer);
544
- nodesToMove.forEach((node) => {
545
- if (!node) return;
546
- if (referenceNode) {
547
- targetContainer.insertBefore(node, referenceNode);
548
- } else {
549
- targetContainer.appendChild(node);
550
- }
551
- });
552
- }
553
-
554
- function listAllTreeNodes() {
555
- const root = document.getElementById('creatives');
556
- if (root) return Array.from(root.querySelectorAll('.creative-tree'));
557
- return Array.from(document.querySelectorAll('.creative-tree'));
558
- }
559
-
560
- function findPreviousTree(tree) {
561
- if (!tree) return null;
562
- const nodes = listAllTreeNodes();
563
- const index = nodes.indexOf(tree);
564
- if (index <= 0) return null;
565
- const currentLevel = getTreeLevel(tree);
566
- for (let i = index - 1; i >= 0; i--) {
567
- const candidate = nodes[i];
568
- if (!candidate) continue;
569
- const candidateLevel = getTreeLevel(candidate);
570
- if (candidateLevel === currentLevel) return candidate;
571
- if (candidateLevel < currentLevel) return null;
572
- }
573
- return null;
574
- }
575
-
576
- function getTreeLevel(tree) {
577
- if (!tree) return 1;
578
- const levelValue = Number(tree.dataset?.level);
579
- if (!Number.isNaN(levelValue) && levelValue > 0) {
580
- return levelValue;
581
- }
582
- const row = treeRowElement(tree);
583
- return readRowLevel(row) || 1;
584
- }
585
-
586
- function updateTreeLevels(tree, delta) {
587
- if (!tree || !delta) return;
588
- const currentLevel = Number(tree.dataset?.level) || 1;
589
- const nextLevel = Math.max(1, currentLevel + delta);
590
- tree.dataset.level = String(nextLevel);
591
- const row = treeRowElement(tree);
592
- if (row) {
593
- row.setAttribute('level', nextLevel);
594
- row.level = nextLevel;
595
- row.requestUpdate?.();
596
- }
597
- const container = childrenContainerForTree(tree);
598
- if (!container) return;
599
- Array.from(container.children || []).forEach((childRow) => {
600
- if (!childRow.matches?.('creative-tree-row')) return;
601
- const childTree = childRow.querySelector('.creative-tree');
602
- if (childTree) {
603
- updateTreeLevels(childTree, delta);
604
- }
605
- });
606
- }
607
-
608
- function setTreeLevel(tree, targetLevel) {
609
- if (!tree || typeof targetLevel !== 'number') return;
610
- const currentLevel = Number(tree.dataset?.level) || 1;
611
- const delta = targetLevel - currentLevel;
612
- if (delta === 0) return;
613
- updateTreeLevels(tree, delta);
614
- }
615
-
616
391
  function updateParentChildrenState(parentId) {
617
392
  if (!parentId) return;
618
393
  const parentTree = document.getElementById(`creative-${parentId}`);
@@ -642,31 +417,6 @@ export function initializeCreativeRowEditor() {
642
417
  scheduleSave();
643
418
  }
644
419
 
645
- function readRowLevel(row) {
646
- if (!row) return null;
647
- if (row.isTitle) return 0;
648
- if (row.getAttribute) {
649
- const levelAttr = row.getAttribute('level');
650
- if (levelAttr) {
651
- const parsed = Number(levelAttr);
652
- if (!Number.isNaN(parsed)) return parsed;
653
- }
654
- }
655
- if (typeof row.level === 'number') {
656
- return row.level;
657
- }
658
- if (row.level) {
659
- const parsed = Number(row.level);
660
- if (!Number.isNaN(parsed)) return parsed;
661
- }
662
- const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
663
- if (tree && tree.dataset?.level) {
664
- const parsed = Number(tree.dataset.level);
665
- if (!Number.isNaN(parsed)) return parsed;
666
- }
667
- return 1;
668
- }
669
-
670
420
  function computeNewRowLevel(parentId, referenceNode, afterId) {
671
421
  if (parentId) {
672
422
  const parentRow = document.querySelector(`creative-tree-row[creative-id="${parentId}"]`);
@@ -685,18 +435,6 @@ export function initializeCreativeRowEditor() {
685
435
  return readRowLevel(normalized);
686
436
  }
687
437
 
688
- function editorPaddingForLevel(level) {
689
- if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
690
- return '0px';
691
- }
692
- if (level <= BULLET_STARTING_LEVEL) {
693
- const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
694
- return emValue ? `${emValue}em` : '0px';
695
- }
696
- const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
697
- return `${pxValue}px`;
698
- }
699
-
700
438
  function syncInlineEditorPadding(source) {
701
439
  if (!template) return;
702
440
  let level = null;
@@ -709,16 +447,6 @@ export function initializeCreativeRowEditor() {
709
447
  template.style.paddingLeft = paddingValue;
710
448
  }
711
449
 
712
- function removeTreeElement(tree) {
713
- if (!tree) return;
714
- const row = treeRowElement(tree);
715
- if (row) {
716
- row.remove();
717
- } else if (tree.remove) {
718
- tree.remove();
719
- }
720
- }
721
-
722
450
  function getUploadCompletion() {
723
451
  if (!uploadCompletionPromise) {
724
452
  uploadCompletionPromise = new Promise(resolve => {
@@ -965,27 +693,6 @@ export function initializeCreativeRowEditor() {
965
693
  if (row) row.style.display = '';
966
694
  }
967
695
 
968
- function creativeTreeElement(node) {
969
- if (!node) return null;
970
- if (node.classList && node.classList.contains('creative-tree')) return node;
971
- if (node.querySelector) {
972
- const inner = node.querySelector('.creative-tree');
973
- if (inner) return inner;
974
- }
975
- return null;
976
- }
977
-
978
- function creativeIdFrom(node) {
979
- const treeEl = creativeTreeElement(node);
980
- if (treeEl && treeEl.dataset) {
981
- return treeEl.dataset.id || '';
982
- }
983
- if (node?.getAttribute) {
984
- return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
985
- }
986
- return '';
987
- }
988
-
989
696
  function insertRow(tree, data) {
990
697
  if (tree.querySelector('.creative-row')) return;
991
698
  const row = document.createElement('div');
@@ -1032,10 +739,18 @@ export function initializeCreativeRowEditor() {
1032
739
  }
1033
740
 
1034
741
  function saveForm(tree = currentTree, parentId = parentInput.value) {
1035
- return waitForUploads().then(function () {
1036
- if (saving) return savePromise;
1037
- clearTimeout(saveTimer);
1038
-
742
+ // Reflect the in-flight save immediately, *before* awaiting pending uploads.
743
+ // Direct-save callers (progress checkbox, structure moves) bypass
744
+ // scheduleSave(), so without this an attachment upload still in flight would
745
+ // let the toolbar keep a stale "saved" label for the whole upload window.
746
+ // Gated on the editor still being bound to this row (mirrors applySaveStatus).
747
+ if (tree === currentTree && (pendingSave || saveQueue.saving || isDirty)) setSaveStatus('pending');
748
+ // Build and run the persist request under the queue's single-flight
749
+ // guard: runExclusive cancels any pending debounce, invokes performSave,
750
+ // and (only when it returns a request for non-empty content) holds the
751
+ // in-flight state until that request settles. Returning null keeps the
752
+ // queue idle without ever flipping the in-flight flag.
753
+ function performSave() {
1039
754
  // Sync markdown form fields before saving
1040
755
  if (markdownMode) syncMarkdownToForm();
1041
756
 
@@ -1044,13 +759,22 @@ export function initializeCreativeRowEditor() {
1044
759
  : isHtmlEmpty(descriptionInput.value);
1045
760
  if (isEmpty) {
1046
761
  pendingSave = false;
1047
- return Promise.resolve();
762
+ // Nothing to persist — don't strand the "pending" label set above.
763
+ if (tree === currentTree) setSaveStatus('');
764
+ return null;
1048
765
  }
1049
766
 
1050
767
  const method = methodInput.value === 'patch' ? 'PATCH' : 'POST';
1051
768
  pendingSave = false;
1052
- if (!form.action) return Promise.resolve();
1053
- saving = true;
769
+ if (!form.action) return null;
770
+ // Only reflect this save's outcome while the editor is still bound to the
771
+ // creative it started on. If the user navigates to another row mid-flight
772
+ // (move() reattaches the shared #inline-save-status span to the new row),
773
+ // a late completion must not mislabel the newly opened row.
774
+ const applySaveStatus = function (state) {
775
+ if (tree === currentTree) setSaveStatus(state);
776
+ };
777
+ applySaveStatus('saving');
1054
778
 
1055
779
  // Capture values being saved to update dirty state on success
1056
780
  // NOTE: `let` (not `const`) — when the server rewrites markdown_source
@@ -1068,8 +792,11 @@ export function initializeCreativeRowEditor() {
1068
792
  if (progressHiddenInput) progressHiddenInput.disabled = true;
1069
793
  }
1070
794
 
1071
- savePromise = creativesApi.save(form.action, method, form).then(function (r) {
1072
- if (!r.ok) return r;
795
+ return creativesApi.save(form.action, method, form).then(function (r) {
796
+ if (!r.ok) {
797
+ applySaveStatus('error');
798
+ return r;
799
+ }
1073
800
  return r.text().then(function (text) {
1074
801
  try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
1075
802
  }).then(function (data) {
@@ -1162,15 +889,26 @@ export function initializeCreativeRowEditor() {
1162
889
  }
1163
890
  }
1164
891
  updateActionButtonStates();
892
+ // Only announce "saved" when the current buffer still matches what we
893
+ // just persisted. Text edits during the in-flight save keep isDirty
894
+ // true; a non-text change (e.g. a second progress toggle) re-arms
895
+ // pendingSave while this early-returns on the shared promise. Either
896
+ // means the latest value isn't persisted, so show "pending".
897
+ applySaveStatus((isDirty || pendingSave) ? 'pending' : 'saved');
1165
898
  });
899
+ }).catch(function (err) {
900
+ // Preserve existing rejection propagation; only surface save status.
901
+ applySaveStatus('error');
902
+ throw err;
1166
903
  }).finally(function () {
1167
- saving = false;
1168
904
  if (!shouldPersistProgress) {
1169
905
  if (progressInput) progressInput.disabled = progressInputsDisabled;
1170
906
  if (progressHiddenInput) progressHiddenInput.disabled = hiddenProgressDisabled;
1171
907
  }
1172
908
  });
1173
- return savePromise;
909
+ }
910
+ return waitForUploads().then(function () {
911
+ return saveQueue.runExclusive(performSave);
1174
912
  });
1175
913
  }
1176
914
 
@@ -1197,7 +935,7 @@ export function initializeCreativeRowEditor() {
1197
935
 
1198
936
  const finalizeHide = function () {
1199
937
  template.style.display = 'none';
1200
- const p = (pendingSave || saving) ? saveForm(tree, parentId) : Promise.resolve();
938
+ const p = (pendingSave || saveQueue.saving) ? saveForm(tree, parentId) : Promise.resolve();
1201
939
  return p.then(() => {
1202
940
  if (wasNew && !form.dataset.creativeId) {
1203
941
  removeTreeElement(tree);
@@ -1254,7 +992,7 @@ export function initializeCreativeRowEditor() {
1254
992
  }
1255
993
 
1256
994
  function beforeNewOrMove(wasNew, prev, prevParent) {
1257
- const needsSave = pendingSave || wasNew || saving;
995
+ const needsSave = pendingSave || wasNew || saveQueue.saving;
1258
996
  const p = needsSave ? saveForm(prev, prevParent) : Promise.resolve();
1259
997
  return p.then(() => {
1260
998
  if (wasNew && !form.dataset.creativeId) {
@@ -1288,8 +1026,12 @@ export function initializeCreativeRowEditor() {
1288
1026
 
1289
1027
  // CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
1290
1028
  // to a different creative while we're waiting for uploads
1291
- const isMarkdownSave = markdownMode;
1292
- if (isMarkdownSave) syncMarkdownToForm();
1029
+ // Both editor surfaces persist Markdown now. The textarea surface
1030
+ // (markdownMode) syncs its value to the hidden fields here; the rich
1031
+ // surface already kept them current via onLexicalChange/applyCreativeData.
1032
+ if (markdownMode) syncMarkdownToForm();
1033
+ const capturedContentType = contentTypeInput ? contentTypeInput.value : 'html';
1034
+ const isMarkdownSave = capturedContentType === 'markdown';
1293
1035
  let currentContent = descriptionInput.value;
1294
1036
  let currentProgress = readProgressValue();
1295
1037
  let shouldPersistProgress = progressValueChanged();
@@ -1297,8 +1039,8 @@ export function initializeCreativeRowEditor() {
1297
1039
  const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
1298
1040
  const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
1299
1041
  const startCreativeId = creativeId;
1300
- let capturedMarkdownSource = isMarkdownSave ? (markdownTextarea?.value || '') : '';
1301
- const capturedContentType = isMarkdownSave ? 'markdown' : 'html';
1042
+ let capturedMarkdownSource = isMarkdownSave ? (markdownSourceInput ? markdownSourceInput.value : '') : '';
1043
+ const capturedMarkdownEditor = markdownEditorInput ? markdownEditorInput.value : '';
1302
1044
 
1303
1045
  // Prevent saving empty content, matching saveForm behavior
1304
1046
  // This avoids overwriting existing descriptions with empty strings during quick navigation
@@ -1320,9 +1062,12 @@ export function initializeCreativeRowEditor() {
1320
1062
  // so we must re-sync and re-capture the latest textarea value too — otherwise edits
1321
1063
  // made during the upload wait get overwritten by the stale pre-wait source.
1322
1064
  if (form.dataset.creativeId === startCreativeId) {
1323
- if (isMarkdownSave) {
1065
+ if (markdownMode) {
1324
1066
  syncMarkdownToForm();
1325
- capturedMarkdownSource = markdownTextarea?.value || '';
1067
+ capturedMarkdownSource = markdownSourceInput ? markdownSourceInput.value : '';
1068
+ } else if (isMarkdownSave && markdownSourceInput) {
1069
+ // Rich surface: re-capture any Markdown produced by edits during the wait.
1070
+ capturedMarkdownSource = markdownSourceInput.value;
1326
1071
  }
1327
1072
  currentContent = descriptionInput.value;
1328
1073
  currentProgress = readProgressValue();
@@ -1338,6 +1083,9 @@ export function initializeCreativeRowEditor() {
1338
1083
  };
1339
1084
  if (isMarkdownSave) {
1340
1085
  body['creative[markdown_source]'] = capturedMarkdownSource;
1086
+ if (capturedMarkdownEditor) {
1087
+ body['creative[markdown_editor]'] = capturedMarkdownEditor;
1088
+ }
1341
1089
  }
1342
1090
 
1343
1091
  if (shouldPersistProgress) {
@@ -1368,6 +1116,10 @@ export function initializeCreativeRowEditor() {
1368
1116
  }
1369
1117
  row.dataset.contentType = capturedContentType;
1370
1118
  row.dataset.markdownSource = isMarkdownSave ? capturedMarkdownSource : '';
1119
+ // Persist which surface authored this save so a row re-opened from this
1120
+ // cached payload (before any full GET refresh) reopens in the right
1121
+ // editor — without it, rich-authored Markdown falls back to the textarea.
1122
+ row.dataset.markdownEditor = isMarkdownSave ? capturedMarkdownEditor : '';
1371
1123
  if (currentParentId) {
1372
1124
  tree.dataset.parentId = currentParentId;
1373
1125
  row.parentId = currentParentId;
@@ -1443,7 +1195,7 @@ export function initializeCreativeRowEditor() {
1443
1195
  }
1444
1196
  isDirty = false;
1445
1197
  pendingSave = false;
1446
- clearTimeout(saveTimer);
1198
+ saveQueue.cancelTimer();
1447
1199
  }
1448
1200
 
1449
1201
  async function move(delta) {
@@ -1588,7 +1340,10 @@ export function initializeCreativeRowEditor() {
1588
1340
  insertBefore = normalizeRowNode(firstChild);
1589
1341
  beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
1590
1342
  } else {
1591
- parentId = prev.dataset.parentId;
1343
+ // parentId convention: `data-parent-id` is always present; "" means
1344
+ // root. Normalize to '' so an absent attribute (legacy DOM) and a
1345
+ // real root are treated identically, and a real parent id is kept.
1346
+ parentId = prev.dataset.parentId || '';
1592
1347
  container = treeContainerElement(prev);
1593
1348
  afterId = prev.dataset.id;
1594
1349
  insertBefore = nodeAfterTreeBlock(prev);
@@ -1894,6 +1649,10 @@ export function initializeCreativeRowEditor() {
1894
1649
  originalProgress = 0;
1895
1650
  if (unconvertBtn) unconvertBtn.style.display = 'none';
1896
1651
  pendingSave = false;
1652
+ isDirty = false;
1653
+ // The status span is shared across rows; clear the previous row's
1654
+ // "saved"/"failed" label so a blank new row doesn't inherit it.
1655
+ setSaveStatus('');
1897
1656
  lexicalEditor.focus();
1898
1657
  updateActionButtonStates();
1899
1658
  document.dispatchEvent(new CustomEvent('creative-editing:start', {
@@ -1925,14 +1684,29 @@ export function initializeCreativeRowEditor() {
1925
1684
  if (creativeId && destroyedCreativeIds.has(String(creativeId))) return;
1926
1685
 
1927
1686
  pendingSave = true;
1928
- clearTimeout(saveTimer);
1929
- saveTimer = setTimeout(saveForm, 5000);
1930
- }
1931
-
1932
- function onLexicalChange(html) {
1933
- if (markdownMode) return; // Ignore Lexical changes in markdown mode
1687
+ // A prior "saved"/"failed" label would otherwise claim the freshly edited
1688
+ // buffer is persisted during the 5s debounce window, so reflect the pending
1689
+ // save the moment the buffer diverges from what was last stored. The request
1690
+ // hasn't started yet, so this is "pending" (waiting), not "saving". Gate on
1691
+ // pendingSave (just set true), not isDirty: structure-only changes (level
1692
+ // up/down, reorder) schedule a save without setting isDirty, and must not
1693
+ // keep showing the previous "saved" label.
1694
+ setSaveStatus('pending');
1695
+ saveQueue.schedule(function () { saveForm(); });
1696
+ }
1697
+
1698
+ function onLexicalChange(payload) {
1699
+ if (markdownMode) return; // Ignore Lexical changes when the textarea is active
1700
+ const html = (payload && payload.html) || '';
1701
+ const markdown = (payload && payload.markdown) || '';
1934
1702
  descriptionInput.value = html;
1935
- // Mark as dirty if content changed from original
1703
+ // The rich editor is now a Markdown-canonical surface: persist the Markdown
1704
+ // projection (text color/background as normalized <span> fragments) and
1705
+ // record that the rich surface authored it, so it reopens in Lexical.
1706
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
1707
+ if (markdownSourceInput) markdownSourceInput.value = markdown;
1708
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
1709
+ // Mark as dirty if the HTML projection changed from original
1936
1710
  isDirty = (html !== originalContent);
1937
1711
  scheduleSave();
1938
1712
  }
@@ -1992,9 +1766,11 @@ export function initializeCreativeRowEditor() {
1992
1766
  let atEnd = false;
1993
1767
  editorInstance.getEditorState().read(() => {
1994
1768
  const selection = $getSelection();
1995
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
1996
- const [start, end] = $getCharacterOffsets(selection);
1997
- atStart = start === 0 && end === 0;
1769
+ // atStart must reflect the start of the whole document, not just offset 0
1770
+ // of the current node — otherwise the start of a second paragraph (e.g.
1771
+ // right after pressing Enter) is mistaken for the top and ArrowUp jumps
1772
+ // to the row above instead of moving the cursor up. See isSelectionAtDocumentStart.
1773
+ atStart = isSelectionAtDocumentStart(selection);
1998
1774
  atEnd = isSelectionAtDocumentEnd(selection);
1999
1775
  });
2000
1776
 
@@ -2014,32 +1790,6 @@ export function initializeCreativeRowEditor() {
2014
1790
  }
2015
1791
  }
2016
1792
 
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
1793
  if (progressInput) {
2044
1794
  progressInput.addEventListener('change', function () {
2045
1795
  if (progressValue) {
@@ -2052,14 +1802,14 @@ export function initializeCreativeRowEditor() {
2052
1802
  completionCascadePending = true;
2053
1803
  const alertMessage = progressInput.dataset.childrenAlertMessage;
2054
1804
  if (alertMessage) {
2055
- alert(alertMessage);
1805
+ alertDialog(alertMessage);
2056
1806
  }
2057
1807
  }
2058
1808
  updateProgressInputAvailability(readProgressValue());
2059
1809
  // Save immediately on checkbox change to prevent losing the last toggle
2060
1810
  // when the user navigates away before the debounce timer fires.
2061
1811
  pendingSave = true;
2062
- clearTimeout(saveTimer);
1812
+ saveQueue.cancelTimer();
2063
1813
  saveForm();
2064
1814
  });
2065
1815
  }
@@ -2138,14 +1888,14 @@ export function initializeCreativeRowEditor() {
2138
1888
  }
2139
1889
 
2140
1890
  if (archiveBtn) {
2141
- archiveBtn.addEventListener('click', function () {
1891
+ archiveBtn.addEventListener('click', async function () {
2142
1892
  const creativeId = form.dataset.creativeId;
2143
1893
  if (!creativeId) return;
2144
1894
  const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`);
2145
1895
  const isArchived = row?.hasAttribute('archived');
2146
1896
  const confirmMsg = isArchived ? archiveBtn.dataset.restoreConfirm : archiveBtn.dataset.confirm;
2147
1897
 
2148
- if (confirm(confirmMsg)) {
1898
+ if (await confirmDialog(confirmMsg)) {
2149
1899
  const apiCall = isArchived ? creativesApi.unarchive(creativeId) : creativesApi.archive(creativeId);
2150
1900
  apiCall.then(res => {
2151
1901
  if (res.ok) {
@@ -2172,14 +1922,14 @@ export function initializeCreativeRowEditor() {
2172
1922
  }
2173
1923
 
2174
1924
  if (deleteBtn) {
2175
- deleteBtn.addEventListener('click', function () {
2176
- if (confirm(deleteBtn.dataset.confirm)) deleteCurrent(false);
1925
+ deleteBtn.addEventListener('click', async function () {
1926
+ if (await confirmDialog(deleteBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
2177
1927
  });
2178
1928
  }
2179
1929
 
2180
1930
  if (deleteWithChildrenBtn) {
2181
- deleteWithChildrenBtn.addEventListener('click', function () {
2182
- if (confirm(deleteWithChildrenBtn.dataset.confirm)) deleteCurrent(true);
1931
+ deleteWithChildrenBtn.addEventListener('click', async function () {
1932
+ if (await confirmDialog(deleteWithChildrenBtn.dataset.confirm, { danger: true })) deleteCurrent(true);
2183
1933
  });
2184
1934
  }
2185
1935
 
@@ -2215,17 +1965,17 @@ export function initializeCreativeRowEditor() {
2215
1965
  }
2216
1966
 
2217
1967
  if (unlinkBtn) {
2218
- unlinkBtn.addEventListener('click', function () {
2219
- if (confirm(unlinkBtn.dataset.confirm)) deleteCurrent(false);
1968
+ unlinkBtn.addEventListener('click', async function () {
1969
+ if (await confirmDialog(unlinkBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
2220
1970
  });
2221
1971
  }
2222
1972
 
2223
1973
  if (unconvertBtn) {
2224
- unconvertBtn.addEventListener('click', function () {
1974
+ unconvertBtn.addEventListener('click', async function () {
2225
1975
  const creativeId = form.dataset.creativeId;
2226
1976
  if (!creativeId) return;
2227
1977
  const confirmText = unconvertBtn.dataset.confirm;
2228
- if (confirmText && !confirm(confirmText)) return;
1978
+ if (confirmText && !(await confirmDialog(confirmText))) return;
2229
1979
  const errorMessage = unconvertBtn.dataset.error || 'Failed to unconvert.';
2230
1980
  unconvertBtn.disabled = true;
2231
1981
  saveForm()
@@ -2235,7 +1985,7 @@ export function initializeCreativeRowEditor() {
2235
1985
  .json()
2236
1986
  .catch(function () { return {}; })
2237
1987
  .then(function (data) {
2238
- alert(data && data.error ? data.error : errorMessage);
1988
+ alertDialog(data && data.error ? data.error : errorMessage);
2239
1989
  const error = new Error('Save failed');
2240
1990
  error._handled = true;
2241
1991
  throw error;
@@ -2252,12 +2002,12 @@ export function initializeCreativeRowEditor() {
2252
2002
  .json()
2253
2003
  .catch(function () { return {}; })
2254
2004
  .then(function (data) {
2255
- alert(data && data.error ? data.error : errorMessage);
2005
+ alertDialog(data && data.error ? data.error : errorMessage);
2256
2006
  });
2257
2007
  })
2258
2008
  .catch(function (error) {
2259
2009
  if (error && error._handled) return;
2260
- alert(errorMessage);
2010
+ alertDialog(errorMessage);
2261
2011
  })
2262
2012
  .finally(function () {
2263
2013
  unconvertBtn.disabled = false;
@@ -2276,7 +2026,7 @@ export function initializeCreativeRowEditor() {
2276
2026
  })
2277
2027
  .catch(function (error) {
2278
2028
  console.error('Failed to load metadata:', error);
2279
- alert('Failed to load metadata');
2029
+ alertDialog('Failed to load metadata');
2280
2030
  });
2281
2031
  }
2282
2032
 
@@ -2321,17 +2071,17 @@ export function initializeCreativeRowEditor() {
2321
2071
  metadataPopup.style.display = 'none';
2322
2072
  } else {
2323
2073
  return response.json().then(function (data) {
2324
- alert('Failed to save metadata: ' + (data.error || 'Unknown error'));
2074
+ alertDialog('Failed to save metadata: ' + (data.error || 'Unknown error'));
2325
2075
  });
2326
2076
  }
2327
2077
  })
2328
2078
  .catch(function (error) {
2329
2079
  console.error('Failed to save metadata:', error);
2330
- alert('Failed to save metadata');
2080
+ alertDialog('Failed to save metadata');
2331
2081
  });
2332
2082
  } catch (error) {
2333
2083
  console.error('YAML parse error:', error);
2334
- alert('Invalid YAML format: ' + error.message);
2084
+ alertDialog('Invalid YAML format: ' + error.message);
2335
2085
  }
2336
2086
  });
2337
2087
  }
@@ -2339,27 +2089,37 @@ export function initializeCreativeRowEditor() {
2339
2089
 
2340
2090
  // Markdown toggle button
2341
2091
  if (toggleMarkdownBtn) {
2342
- toggleMarkdownBtn.addEventListener('click', function () {
2092
+ toggleMarkdownBtn.addEventListener('click', async function () {
2343
2093
  if (markdownMode) {
2344
- // Switching from Markdown → Rich Text
2094
+ // Switching from Markdown (advanced textarea) → Rich Text. The Markdown
2095
+ // stays canonical; we only flip the authoring surface to rich so it
2096
+ // reopens in Lexical, and render it to HTML for the editor view.
2345
2097
  const confirmMsg = toggleMarkdownBtn.dataset.confirmToRichtext;
2346
- if (confirmMsg && !confirm(confirmMsg)) return;
2098
+ if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
2347
2099
  const md = markdownTextarea?.value || '';
2348
2100
  const html = md ? renderMarkdown(md) : '';
2349
2101
  deactivateMarkdownMode();
2350
2102
  descriptionInput.value = html;
2103
+ if (md) {
2104
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
2105
+ if (markdownSourceInput) markdownSourceInput.value = md;
2106
+ if (markdownEditorInput) markdownEditorInput.value = 'rich';
2107
+ }
2351
2108
  lexicalEditor.load(html, `creative-switch-${Date.now()}`);
2352
2109
  lexicalEditor.focus();
2353
2110
  isDirty = true;
2354
2111
  scheduleSave();
2355
2112
  } else {
2356
- // Switching from Rich Text → Markdown
2113
+ // Switching from Rich Text → Markdown (advanced textarea). Preserve the
2114
+ // content by seeding the textarea with the rich surface's current
2115
+ // Markdown projection instead of discarding it.
2357
2116
  const currentHtml = descriptionInput.value || '';
2358
2117
  if (!isHtmlEmpty(currentHtml)) {
2359
2118
  const confirmMsg = toggleMarkdownBtn.dataset.confirmToMarkdown;
2360
- if (confirmMsg && !confirm(confirmMsg)) return;
2119
+ if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
2361
2120
  }
2362
- activateMarkdownMode('');
2121
+ const existingMarkdown = markdownSourceInput ? markdownSourceInput.value : '';
2122
+ activateMarkdownMode(existingMarkdown);
2363
2123
  isDirty = true;
2364
2124
  scheduleSave();
2365
2125
  }