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
@@ -0,0 +1,76 @@
1
+ // Pure, closure-independent helpers extracted from creative_row_editor.js.
2
+ //
3
+ // Every function here depends only on its arguments (and the module-local
4
+ // layout constants below) — none captures editor closure state. Keeping them in
5
+ // a separate module shrinks the main editor module and makes these units
6
+ // individually testable. Behavior is identical to the original inline versions.
7
+
8
+ const BULLET_STARTING_LEVEL = 3;
9
+ const HEADING_INDENT_STEP_EM = 0.4;
10
+ const BULLET_INDENT_STEP_PX = 30;
11
+
12
+ export function treeRowElement(node) {
13
+ return node && node.closest ? node.closest('creative-tree-row') : null;
14
+ }
15
+
16
+ export function hasDatasetValue(element, key) {
17
+ if (!element || !element.dataset) return false;
18
+ return Object.prototype.hasOwnProperty.call(element.dataset, key);
19
+ }
20
+
21
+ export function setRowDatasetValue(row, key, value) {
22
+ if (!row || !row.dataset) return;
23
+ if (value === undefined || value === null) {
24
+ delete row.dataset[key];
25
+ } else {
26
+ row.dataset[key] = String(value);
27
+ }
28
+ }
29
+
30
+ export function isMarkdownEmpty(md) {
31
+ return !md || md.trim().length === 0;
32
+ }
33
+
34
+ export function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
35
+ const params = new URLSearchParams();
36
+ params.set('level', String(childLevel));
37
+ params.set('select_mode', selectMode ? '1' : '0');
38
+ return `/creatives/${parentId}/children?${params.toString()}`;
39
+ }
40
+
41
+ export function readRowLevel(row) {
42
+ if (!row) return null;
43
+ if (row.isTitle) return 0;
44
+ if (row.getAttribute) {
45
+ const levelAttr = row.getAttribute('level');
46
+ if (levelAttr) {
47
+ const parsed = Number(levelAttr);
48
+ if (!Number.isNaN(parsed)) return parsed;
49
+ }
50
+ }
51
+ if (typeof row.level === 'number') {
52
+ return row.level;
53
+ }
54
+ if (row.level) {
55
+ const parsed = Number(row.level);
56
+ if (!Number.isNaN(parsed)) return parsed;
57
+ }
58
+ const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
59
+ if (tree && tree.dataset?.level) {
60
+ const parsed = Number(tree.dataset.level);
61
+ if (!Number.isNaN(parsed)) return parsed;
62
+ }
63
+ return 1;
64
+ }
65
+
66
+ export function editorPaddingForLevel(level) {
67
+ if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
68
+ return '0px';
69
+ }
70
+ if (level <= BULLET_STARTING_LEVEL) {
71
+ const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
72
+ return emValue ? `${emValue}em` : '0px';
73
+ }
74
+ const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
75
+ return `${pxValue}px`;
76
+ }
@@ -0,0 +1,95 @@
1
+ // The autosave request lifecycle extracted from creative_row_editor.js.
2
+ //
3
+ // The inline editor persists edits on a debounce and also saves directly on
4
+ // structural changes (reorder, indent, progress toggle). Two invariants kept
5
+ // getting re-broken across dozens of fix commits ("Prevent double saves...",
6
+ // "Skip in-flight request during queue deduplication"):
7
+ //
8
+ // 1. Debounce: rapid edits collapse into a single trailing save; re-arming
9
+ // the timer must cancel the previous one.
10
+ // 2. Single-flight: at most one save request is in flight at a time. A save
11
+ // requested while one is running must NOT start a second request — it
12
+ // returns the in-flight promise. (Dirty-tracking in the editor re-arms and
13
+ // a later schedule/flush persists the newer buffer.)
14
+ //
15
+ // This class owns exactly those two concerns — the debounce timer and the
16
+ // single-flight guard around the request promise. It deliberately does NOT own
17
+ // the editor's dirty flags (isDirty / pendingSave), which track *what* changed;
18
+ // this owns *when the request runs*. Behavior is identical to the original
19
+ // inline `saveTimer` / `saving` / `savePromise` logic; the timer functions are
20
+ // injectable so the state machine is testable without real timers.
21
+ export class CreativeSaveQueue {
22
+ constructor({ debounceMs = 5000, setTimer, clearTimer } = {}) {
23
+ this._debounceMs = debounceMs;
24
+ // Wrap the timer functions so they are invoked as free functions. Passing
25
+ // `setTimeout`/`clearTimeout` directly and calling them as `this._setTimer(...)`
26
+ // would invoke them with `this === queue`, which throws "Illegal invocation"
27
+ // in real browsers (jsdom does not, which is why unit tests can't catch it).
28
+ // Tests inject their own timer fns; production uses these window-bound wrappers.
29
+ this._setTimer = setTimer || ((fn, ms) => setTimeout(fn, ms));
30
+ this._clearTimer = clearTimer || ((id) => clearTimeout(id));
31
+ this._timer = null;
32
+ this._saving = false;
33
+ this._promise = Promise.resolve();
34
+ }
35
+
36
+ /** True while a save request is in flight. */
37
+ get saving() {
38
+ return this._saving;
39
+ }
40
+
41
+ /** The most recent (possibly in-flight) save promise. */
42
+ get promise() {
43
+ return this._promise;
44
+ }
45
+
46
+ /**
47
+ * Arm the debounce timer to invoke `flush` after `debounceMs`. Re-arming
48
+ * cancels any previously scheduled flush, so only the latest edit's timer
49
+ * fires (trailing debounce).
50
+ * @param {() => void} flush
51
+ */
52
+ schedule(flush) {
53
+ this.cancelTimer();
54
+ this._timer = this._setTimer(() => {
55
+ this._timer = null;
56
+ flush();
57
+ }, this._debounceMs);
58
+ }
59
+
60
+ /** Cancel a pending debounce timer, if any. Idempotent. */
61
+ cancelTimer() {
62
+ if (this._timer !== null) {
63
+ this._clearTimer(this._timer);
64
+ this._timer = null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Run `execute` under the single-flight guard.
70
+ *
71
+ * - If a save is already in flight, returns the in-flight promise WITHOUT
72
+ * invoking `execute` (the coalescing invariant).
73
+ * - Otherwise cancels any pending debounce, then invokes `execute`. `execute`
74
+ * returns the save request promise, or a nullish value to signal "nothing
75
+ * to persist" (e.g. empty content) — in which case no in-flight state is
76
+ * entered and a resolved promise is returned. Invoking `execute` before
77
+ * committing `saving` preserves the original ordering, where an empty-save
78
+ * short-circuit never flips the in-flight flag.
79
+ *
80
+ * `saving` is cleared when the returned promise settles (success or failure).
81
+ * @param {() => (Promise|null|undefined)} execute
82
+ * @returns {Promise}
83
+ */
84
+ runExclusive(execute) {
85
+ if (this._saving) return this._promise;
86
+ this.cancelTimer();
87
+ const request = execute();
88
+ if (request == null) return Promise.resolve();
89
+ this._saving = true;
90
+ this._promise = Promise.resolve(request).finally(() => {
91
+ this._saving = false;
92
+ });
93
+ return this._promise;
94
+ }
95
+ }
@@ -0,0 +1,260 @@
1
+ // Tree-DOM navigation & geometry helpers extracted from creative_row_editor.js.
2
+ //
3
+ // This is slice 2 of the creative_row_editor god-file decomposition (slice 1 was
4
+ // the save-queue single-flight/debounce cluster, already in creative_save_queue.js).
5
+ //
6
+ // Every function here operates purely on the DOM nodes passed as arguments (plus
7
+ // `document` for id/selector lookups) — none captures the editor closure's shared
8
+ // state (currentTree, form, parentInput, isDirty, pendingSave, uploadsPending, …).
9
+ // That is exactly why this cluster is a clean seam: it can be lifted out and unit
10
+ // tested against a jsdom fixture without standing up the whole inline editor.
11
+ //
12
+ // The cluster underpins the structural-move paths (levelUp / levelDown / addNew /
13
+ // move) that compute a node's parent, its before/after siblings, and its indent
14
+ // level — the inputs to the recurring "parentId loss / wrong ordering" bug class.
15
+ // Pinning them with characterization tests raises the floor under those moves.
16
+ //
17
+ // NOTE ON BINDING HAZARDS: none of these functions use setTimeout / setInterval /
18
+ // requestAnimationFrame / queueMicrotask, and none rely on `this`. They are plain
19
+ // functions invoked by name, so the "Illegal invocation" class of real-browser
20
+ // regression (a prior timer-extraction bug that jsdom could not catch) does not
21
+ // apply here. The only DOM writes are element mutations (insertBefore, appendChild,
22
+ // setAttribute, dataset, style, requestUpdate) invoked on the passed nodes.
23
+ import { treeRowElement, readRowLevel, buildChildrenLoadUrl } from './creative_row_editor_helpers';
24
+
25
+ export function creativeTreeElement(node) {
26
+ if (!node) return null;
27
+ if (node.classList && node.classList.contains('creative-tree')) return node;
28
+ if (node.querySelector) {
29
+ const inner = node.querySelector('.creative-tree');
30
+ if (inner) return inner;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function creativeIdFrom(node) {
36
+ const treeEl = creativeTreeElement(node);
37
+ if (treeEl && treeEl.dataset) {
38
+ return treeEl.dataset.id || '';
39
+ }
40
+ if (node?.getAttribute) {
41
+ return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
42
+ }
43
+ return '';
44
+ }
45
+
46
+ export function siblingTreeRow(row, direction) {
47
+ if (!row) return null;
48
+ const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
49
+ let node = row[step];
50
+ while (node) {
51
+ if (node.nodeType === Node.TEXT_NODE) {
52
+ node = node[step];
53
+ continue;
54
+ }
55
+ if (node.matches?.('creative-tree-row')) return node;
56
+ if (node.classList?.contains?.('creative-children')) {
57
+ node = node[step];
58
+ continue;
59
+ }
60
+ node = node[step];
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function siblingOrderingForRow(row) {
66
+ const beforeRow = siblingTreeRow(row, 'next');
67
+ const afterRow = siblingTreeRow(row, 'previous');
68
+ return {
69
+ beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
70
+ afterId: afterRow ? creativeIdFrom(afterRow) : ''
71
+ };
72
+ }
73
+
74
+ export function treeContainerElement(tree) {
75
+ if (!tree) return null;
76
+ const row = treeRowElement(tree);
77
+ if (row && row.parentNode) return row.parentNode;
78
+ return tree.parentNode;
79
+ }
80
+
81
+ export function nodeAfterTreeBlock(tree) {
82
+ if (!tree) return null;
83
+ const row = treeRowElement(tree);
84
+ if (!row) return tree.nextSibling;
85
+ let node = row.nextSibling;
86
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
87
+ const treeId = tree.dataset?.id;
88
+ if (treeId) {
89
+ const childrenContainer = document.getElementById(`creative-children-${treeId}`);
90
+ if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
91
+ node = childrenContainer.nextSibling;
92
+ while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
93
+ }
94
+ }
95
+ return node;
96
+ }
97
+
98
+ export function normalizeRowNode(node) {
99
+ if (!node) return null;
100
+ if (node.matches && node.matches('creative-tree-row')) return node;
101
+ if (node.classList && node.classList.contains('creative-tree')) {
102
+ const row = treeRowElement(node);
103
+ return row || node;
104
+ }
105
+ return node;
106
+ }
107
+
108
+ export function childrenContainerForTree(tree) {
109
+ if (!tree) return null;
110
+ const treeId = tree.dataset?.id;
111
+ if (treeId) {
112
+ const byId = document.getElementById(`creative-children-${treeId}`);
113
+ if (byId) return byId;
114
+ }
115
+ if (tree.children && tree.children.length > 0) {
116
+ for (const child of tree.children) {
117
+ if (child && child.classList && child.classList.contains('creative-children')) {
118
+ return child;
119
+ }
120
+ }
121
+ }
122
+ const row = treeRowElement(tree);
123
+ if (row) {
124
+ let sibling = row.nextElementSibling;
125
+ while (sibling) {
126
+ if (sibling.matches?.('creative-tree-row')) break;
127
+ if (sibling.classList?.contains('creative-children')) return sibling;
128
+ sibling = sibling.nextElementSibling;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ export function ensureChildrenContainer(tree) {
135
+ if (!tree) return null;
136
+ let container = childrenContainerForTree(tree);
137
+ if (container) return container;
138
+ const parentId = tree.dataset?.id;
139
+ if (!parentId) return null;
140
+ container = document.createElement('div');
141
+ container.className = 'creative-children';
142
+ container.id = `creative-children-${parentId}`;
143
+ const parentRow = treeRowElement(tree);
144
+ const parentLevel = readRowLevel(parentRow) || 1;
145
+ const childLevel = parentLevel + 1;
146
+ const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
147
+ container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
148
+ container.dataset.expanded = 'true';
149
+ if (container.dataset.loaded) delete container.dataset.loaded;
150
+ const row = treeRowElement(tree);
151
+ const parentContainer = row?.parentNode || tree.parentNode;
152
+ if (parentContainer) {
153
+ const afterRow = row?.nextSibling;
154
+ if (afterRow) {
155
+ parentContainer.insertBefore(container, afterRow);
156
+ } else {
157
+ parentContainer.appendChild(container);
158
+ }
159
+ } else {
160
+ tree.appendChild(container);
161
+ }
162
+ return container;
163
+ }
164
+
165
+ export function expandChildrenContainer(container) {
166
+ if (!container) return;
167
+ container.style.display = '';
168
+ if (container.dataset) {
169
+ container.dataset.expanded = 'true';
170
+ }
171
+ }
172
+
173
+ export function moveTreeBlock(tree, targetContainer, referenceNode = null) {
174
+ if (!tree || !targetContainer) return;
175
+ const row = treeRowElement(tree);
176
+ if (!row) return;
177
+ const nodesToMove = [row];
178
+ const childContainer = childrenContainerForTree(tree);
179
+ if (childContainer) nodesToMove.push(childContainer);
180
+ nodesToMove.forEach((node) => {
181
+ if (!node) return;
182
+ if (referenceNode) {
183
+ targetContainer.insertBefore(node, referenceNode);
184
+ } else {
185
+ targetContainer.appendChild(node);
186
+ }
187
+ });
188
+ }
189
+
190
+ export function listAllTreeNodes() {
191
+ const root = document.getElementById('creatives');
192
+ if (root) return Array.from(root.querySelectorAll('.creative-tree'));
193
+ return Array.from(document.querySelectorAll('.creative-tree'));
194
+ }
195
+
196
+ export function findPreviousTree(tree) {
197
+ if (!tree) return null;
198
+ const nodes = listAllTreeNodes();
199
+ const index = nodes.indexOf(tree);
200
+ if (index <= 0) return null;
201
+ const currentLevel = getTreeLevel(tree);
202
+ for (let i = index - 1; i >= 0; i--) {
203
+ const candidate = nodes[i];
204
+ if (!candidate) continue;
205
+ const candidateLevel = getTreeLevel(candidate);
206
+ if (candidateLevel === currentLevel) return candidate;
207
+ if (candidateLevel < currentLevel) return null;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ export function getTreeLevel(tree) {
213
+ if (!tree) return 1;
214
+ const levelValue = Number(tree.dataset?.level);
215
+ if (!Number.isNaN(levelValue) && levelValue > 0) {
216
+ return levelValue;
217
+ }
218
+ const row = treeRowElement(tree);
219
+ return readRowLevel(row) || 1;
220
+ }
221
+
222
+ export function updateTreeLevels(tree, delta) {
223
+ if (!tree || !delta) return;
224
+ const currentLevel = Number(tree.dataset?.level) || 1;
225
+ const nextLevel = Math.max(1, currentLevel + delta);
226
+ tree.dataset.level = String(nextLevel);
227
+ const row = treeRowElement(tree);
228
+ if (row) {
229
+ row.setAttribute('level', nextLevel);
230
+ row.level = nextLevel;
231
+ row.requestUpdate?.();
232
+ }
233
+ const container = childrenContainerForTree(tree);
234
+ if (!container) return;
235
+ Array.from(container.children || []).forEach((childRow) => {
236
+ if (!childRow.matches?.('creative-tree-row')) return;
237
+ const childTree = childRow.querySelector('.creative-tree');
238
+ if (childTree) {
239
+ updateTreeLevels(childTree, delta);
240
+ }
241
+ });
242
+ }
243
+
244
+ export function setTreeLevel(tree, targetLevel) {
245
+ if (!tree || typeof targetLevel !== 'number') return;
246
+ const currentLevel = Number(tree.dataset?.level) || 1;
247
+ const delta = targetLevel - currentLevel;
248
+ if (delta === 0) return;
249
+ updateTreeLevels(tree, delta);
250
+ }
251
+
252
+ export function removeTreeElement(tree) {
253
+ if (!tree) return;
254
+ const row = treeRowElement(tree);
255
+ if (row) {
256
+ row.remove();
257
+ } else if (tree.remove) {
258
+ tree.remove();
259
+ }
260
+ }
@@ -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
  }