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,87 @@
1
+ /**
2
+ * Regression: parentId single source of truth.
3
+ *
4
+ * The tree row component used to render `data-parent-id=${this.parentId ?? nothing}`,
5
+ * which OMITTED the attribute whenever parentId was null. `addNew` derives a new
6
+ * sibling's parent from `prev.dataset.parentId`, so an absent attribute produced an
7
+ * `undefined` parent downstream and lost the real parent for newly-added siblings.
8
+ *
9
+ * The single convention: `data-parent-id` is ALWAYS emitted on `.creative-tree`.
10
+ * A non-empty value is the parent id; "" means "root / no parent". This mirrors the
11
+ * dynamic-creation path (`dataset.parentId = parentId || ''`).
12
+ */
13
+
14
+ // The component calls `customElements.define(...)` at module-eval time and reads
15
+ // `customElements` as a bare global. The jest environment only exposes it on
16
+ // `window`, so bridge it before importing the component.
17
+ beforeAll(() => {
18
+ if (typeof globalThis.customElements === "undefined") {
19
+ globalThis.customElements = window.customElements;
20
+ }
21
+ });
22
+
23
+ async function mountRow(props) {
24
+ await import("../creative_tree_row.js");
25
+ const el = document.createElement("creative-tree-row");
26
+ Object.assign(el, props);
27
+ document.body.appendChild(el);
28
+ await el.updateComplete;
29
+ return el;
30
+ }
31
+
32
+ // The `.creative-tree` element is what the editor reads via `tree.dataset.parentId`.
33
+ function treeOf(el) {
34
+ return el.querySelector(".creative-tree");
35
+ }
36
+
37
+ afterEach(() => {
38
+ document.body.innerHTML = "";
39
+ });
40
+
41
+ describe("creative-tree-row parentId convention", () => {
42
+ test("non-title row under a page-title parent keeps the parent id", async () => {
43
+ const el = await mountRow({ creativeId: "8", parentId: "5", level: 2 });
44
+ const tree = treeOf(el);
45
+
46
+ // The attribute is always present and carries the real parent id — this is
47
+ // exactly what addNew reads (`prev.dataset.parentId`) when deriving a sibling.
48
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
49
+ expect(tree.dataset.parentId).toBe("5");
50
+ });
51
+
52
+ test("root row emits an explicit empty marker instead of omitting the attribute", async () => {
53
+ const el = await mountRow({ creativeId: "5", parentId: null, level: 1 });
54
+ const tree = treeOf(el);
55
+
56
+ // Before the fix this attribute was omitted (ambiguous root vs unknown).
57
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
58
+ expect(tree.dataset.parentId).toBe("");
59
+ });
60
+
61
+ test("title row preserves its own parent (nested page) instead of dropping it", async () => {
62
+ const el = await mountRow({ creativeId: "5", parentId: "2", isTitle: true });
63
+ const tree = treeOf(el);
64
+
65
+ expect(tree.classList.contains("creative-tree-title")).toBe(true);
66
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
67
+ expect(tree.dataset.parentId).toBe("2");
68
+ });
69
+
70
+ test("root title row emits explicit empty marker", async () => {
71
+ const el = await mountRow({ creativeId: "5", parentId: null, isTitle: true });
72
+ const tree = treeOf(el);
73
+
74
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
75
+ expect(tree.dataset.parentId).toBe("");
76
+ });
77
+
78
+ test("addNew sibling derivation reads the correct parent from a page-title child", async () => {
79
+ // Simulate the editor's else-branch derivation: parentId = prev.dataset.parentId
80
+ // for a child row that lives under a page-title parent (id 5).
81
+ const child = await mountRow({ creativeId: "8", parentId: "5", level: 2 });
82
+ const prev = treeOf(child);
83
+
84
+ const derivedParentId = prev.dataset.parentId || "";
85
+ expect(derivedParentId).toBe("5"); // sibling inherits the page-title parent
86
+ });
87
+ });
@@ -1,7 +1,9 @@
1
1
  import { LitElement, html, svg, nothing } from "lit";
2
- import DOMPurify from "dompurify";
3
2
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
3
  import { parseEmojis } from "../utils/emoji_parser";
4
+ import { highlightCodeBlocks } from "../lib/utils/markdown";
5
+ import { addCreativeTableDownloadButtons } from "../lib/utils/table_download";
6
+ import { sanitizeDescriptionHtml } from "../lib/utils/sanitize_description";
5
7
  import csrfFetch from "../lib/api/csrf_fetch";
6
8
 
7
9
  const BULLET_STARTING_LEVEL = 3;
@@ -78,6 +80,19 @@ class CreativeTreeRow extends LitElement {
78
80
  updated(changedProperties) {
79
81
  this._attachHandlers();
80
82
 
83
+ // Re-tokenize the server-rendered description code blocks with hljs so they
84
+ // match the editor's palette and follow light/dark theme. Idempotent: only
85
+ // unmarked blocks are processed, and lit re-renders description DOM (dropping
86
+ // the marker) only when descriptionHtml actually changes.
87
+ highlightCodeBlocks(this);
88
+
89
+ // Attach CSV/Excel download toolbars to markdown tables in the description
90
+ // display areas so creative tables match the chat-message table UI.
91
+ // Scoped to .creative-content/.creative-title-content (not the whole row)
92
+ // so the inline editor's own tables are never wrapped. Idempotent, safe on
93
+ // every Lit re-render.
94
+ addCreativeTableDownloadButtons(this);
95
+
81
96
  if (changedProperties.has('loadingChildren')) {
82
97
  if (this.loadingChildren) {
83
98
  this._startAnimation();
@@ -99,8 +114,10 @@ class CreativeTreeRow extends LitElement {
99
114
 
100
115
  set descriptionHtml(value) {
101
116
  const oldValue = this._descriptionHtml;
102
- // Always sanitize when setting new HTML
103
- const sanitized = DOMPurify.sanitize(value ?? "");
117
+ // Always sanitize when setting new HTML. Uses the shared sanitizer so the
118
+ // server-generated YouTube preview iframe survives (default DOMPurify config
119
+ // strips all iframes, which is what broke the YouTube link preview).
120
+ const sanitized = sanitizeDescriptionHtml(value);
104
121
  this._descriptionHtml = sanitized;
105
122
  this.dataset.descriptionHtml = sanitized;
106
123
  this.requestUpdate("descriptionHtml", oldValue);
@@ -191,6 +208,12 @@ class CreativeTreeRow extends LitElement {
191
208
  if (this._progressToggle) this._progressToggle.addEventListener("click", this._handleProgressToggle, { passive: false });
192
209
  }
193
210
 
211
+ // parentId convention (single source of truth): `data-parent-id` is ALWAYS
212
+ // emitted on the `.creative-tree` element. A non-empty value is the parent
213
+ // creative id; an empty string ("") means "root / no parent". This mirrors
214
+ // the dynamic-creation path (`dataset.parentId = parentId || ''`) so readers
215
+ // never have to disambiguate an *absent* attribute (unknown) from a real
216
+ // root, which previously dropped the parent for newly-added siblings.
194
217
  render() {
195
218
  if (this.isTitle) {
196
219
  return this._renderTitle();
@@ -207,7 +230,7 @@ class CreativeTreeRow extends LitElement {
207
230
  class="creative-tree"
208
231
  id=${this.domId ?? nothing}
209
232
  data-id=${this.creativeId ?? nothing}
210
- data-parent-id=${this.parentId ?? nothing}
233
+ data-parent-id=${this.parentId ?? ""}
211
234
  data-level=${this.level ?? nothing}
212
235
  draggable=${draggableAttr}
213
236
  data-action=${dragActions}
@@ -233,7 +256,7 @@ class CreativeTreeRow extends LitElement {
233
256
  class="creative-tree creative-tree-title"
234
257
  id=${this.domId ?? nothing}
235
258
  data-id=${this.creativeId ?? nothing}
236
- data-parent-id=${this.parentId ?? nothing}
259
+ data-parent-id=${this.parentId ?? ""}
237
260
  >
238
261
  <div class="creative-row" style="background-color: transparent;" data-creatives--select-mode-target="row">
239
262
  <div class="creative-row-start" style="align-items: center;">
@@ -0,0 +1,16 @@
1
+ import { useEffect } from "react"
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
3
+ import { registerListTabIndentation } from "../../lib/lexical/list_tab_indent"
4
+
5
+ /**
6
+ * Enables Tab / Shift+Tab nesting for list items in the inline editor.
7
+ * The actual command handling lives in registerListTabIndentation so it can be
8
+ * unit-tested against a headless editor.
9
+ */
10
+ export default function ListTabIndentPlugin() {
11
+ const [editor] = useLexicalComposerContext()
12
+
13
+ useEffect(() => registerListTabIndentation(editor), [editor])
14
+
15
+ return null
16
+ }
@@ -6,16 +6,19 @@ import {
6
6
  ORDERED_LIST,
7
7
  CODE
8
8
  } from "@lexical/markdown"
9
+ import { registerCodeFenceShortcut } from "../../lib/lexical/code_fence_shortcut"
9
10
 
10
11
  /**
11
12
  * Markdown-style shortcuts for the creative inline editor:
12
13
  *
13
14
  * - "* " / "- " / "+ " at line start → unordered list
14
15
  * - "1. " (any number) at line start → ordered list
15
- * - "```" + space at line start → code block
16
+ * - "```" (optionally "```lang") on its own line → code block
16
17
  *
17
- * These fire on text change (not on Enter), so they don't conflict
18
- * with the Enter→addNew() shortcut on desktop.
18
+ * The list transformers fire on text change. The built-in CODE transformer
19
+ * needs "``` " (trailing space); registerCodeFenceShortcut adds the Enter path,
20
+ * converting a "```" (or "```lang") line to a code block when Enter is pressed —
21
+ * so the language can be typed before the fence commits.
19
22
  */
20
23
  const CREATIVE_MARKDOWN_TRANSFORMERS = [
21
24
  UNORDERED_LIST,
@@ -27,7 +30,15 @@ export default function MarkdownShortcutsPlugin() {
27
30
  const [editor] = useLexicalComposerContext()
28
31
 
29
32
  useEffect(() => {
30
- return registerMarkdownShortcuts(editor, CREATIVE_MARKDOWN_TRANSFORMERS)
33
+ const unregisterShortcuts = registerMarkdownShortcuts(
34
+ editor,
35
+ CREATIVE_MARKDOWN_TRANSFORMERS
36
+ )
37
+ const unregisterFence = registerCodeFenceShortcut(editor)
38
+ return () => {
39
+ unregisterShortcuts()
40
+ unregisterFence()
41
+ }
31
42
  }, [editor])
32
43
 
33
44
  return null
@@ -0,0 +1,405 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
4
+ import { useLexicalEditable } from "@lexical/react/useLexicalEditable"
5
+ import {
6
+ $deleteTableColumnAtSelection,
7
+ $deleteTableRowAtSelection,
8
+ $getTableAndElementByKey,
9
+ $getTableColumnIndexFromTableCellNode,
10
+ $getTableRowIndexFromTableCellNode,
11
+ $insertTableColumnAtSelection,
12
+ $insertTableRowAtSelection,
13
+ $isTableCellNode,
14
+ $isTableNode,
15
+ getTableElement,
16
+ TableNode
17
+ } from "@lexical/table"
18
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils"
19
+ import { $getNearestNodeFromDOMNode, isHTMLElement } from "lexical"
20
+
21
+ // Cell-boundary action buttons, all driven purely by `mousemove` (never by focus
22
+ // or selection): hover near a table's bottom edge to append a row, near its right
23
+ // edge to append a column, and hover any cell to reveal "×" buttons in the row's
24
+ // left gutter / the column's top gutter that delete that row / column.
25
+ //
26
+ // Delete used to live in a chevron dropdown (TableActionMenuPlugin) that tracked
27
+ // the editor selection and portaled a floating menu. On desktop, clicking the
28
+ // chevron stole focus from the contenteditable, which collapsed the selection out
29
+ // of the editor and made the menu flash-then-close while the chevron bounced — a
30
+ // focus/selection/render-timing race that could not be reproduced headless and
31
+ // survived four positioning fixes. This mousemove-driven approach has none of that
32
+ // machinery (no selection tracking, no $moveMenu, no click-outside, no focus
33
+ // dependency), which is exactly why the add "+" buttons always worked on PC and
34
+ // mobile alike. Ported/extended from the Lexical playground's TableHoverActions.
35
+
36
+ const BUTTON_WIDTH_PX = 20
37
+ const DELETE_BUTTON_PX = 18
38
+
39
+ // Build a CSS selector from a theme class name (mirrors the playground helper).
40
+ function getThemeSelector(getTheme, name) {
41
+ const className = getTheme()?.[name]
42
+ if (typeof className !== "string") {
43
+ throw new Error(`getThemeSelector: required theme property ${name} not defined`)
44
+ }
45
+ return className
46
+ .split(/\s+/g)
47
+ .map((cls) => `.${cls}`)
48
+ .join()
49
+ }
50
+
51
+ // Trailing-edge debounce with a maxWait ceiling and a cancel() method.
52
+ function useDebounce(fn, ms, maxWait) {
53
+ const fnRef = useRef(fn)
54
+ fnRef.current = fn
55
+ return useMemo(() => {
56
+ let timer = null
57
+ let firstCall = 0
58
+ const invoke = (args) => {
59
+ timer = null
60
+ firstCall = 0
61
+ fnRef.current(...args)
62
+ }
63
+ const debounced = (...args) => {
64
+ const now = Date.now()
65
+ if (!timer) firstCall = now
66
+ if (timer) clearTimeout(timer)
67
+ const waitedFor = now - firstCall
68
+ const remainingMax = maxWait != null ? Math.max(0, maxWait - waitedFor) : Infinity
69
+ const delay = Math.min(ms, remainingMax)
70
+ timer = setTimeout(() => invoke(args), delay)
71
+ }
72
+ debounced.cancel = () => {
73
+ if (timer) clearTimeout(timer)
74
+ timer = null
75
+ firstCall = 0
76
+ }
77
+ return debounced
78
+ }, [ms, maxWait])
79
+ }
80
+
81
+ function getMouseInfo(event, getTheme) {
82
+ const target = event.target
83
+ const tableCellClass = getThemeSelector(getTheme, "tableCell")
84
+
85
+ if (isHTMLElement(target)) {
86
+ const tableDOMNode = target.closest(`td${tableCellClass}, th${tableCellClass}`)
87
+
88
+ const isOutside = !(
89
+ tableDOMNode ||
90
+ target.closest(`button${getThemeSelector(getTheme, "tableAddRows")}`) ||
91
+ target.closest(`button${getThemeSelector(getTheme, "tableAddColumns")}`) ||
92
+ target.closest(`button${getThemeSelector(getTheme, "tableDeleteRows")}`) ||
93
+ target.closest(`button${getThemeSelector(getTheme, "tableDeleteColumns")}`) ||
94
+ target.closest("div.TableCellResizer__resizer")
95
+ )
96
+
97
+ return { isOutside, tableDOMNode }
98
+ }
99
+ return { isOutside: true, tableDOMNode: null }
100
+ }
101
+
102
+ function TableHoverActionsContainer({ anchorElem }) {
103
+ const [editor, { getTheme }] = useLexicalComposerContext()
104
+ const isEditable = useLexicalEditable()
105
+ const [isShownRow, setShownRow] = useState(false)
106
+ const [isShownColumn, setShownColumn] = useState(false)
107
+ const [isShownDeleteRow, setShownDeleteRow] = useState(false)
108
+ const [isShownDeleteColumn, setShownDeleteColumn] = useState(false)
109
+ const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false)
110
+ const [position, setPosition] = useState({})
111
+ const [deleteRowPosition, setDeleteRowPosition] = useState({})
112
+ const [deleteColumnPosition, setDeleteColumnPosition] = useState({})
113
+ const tableSetRef = useRef(new Set())
114
+ const tableCellDOMNodeRef = useRef(null)
115
+
116
+ const hideAll = () => {
117
+ setShownRow(false)
118
+ setShownColumn(false)
119
+ setShownDeleteRow(false)
120
+ setShownDeleteColumn(false)
121
+ }
122
+
123
+ const debouncedOnMouseMove = useDebounce(
124
+ (event) => {
125
+ const { isOutside, tableDOMNode } = getMouseInfo(event, getTheme)
126
+
127
+ if (isOutside) {
128
+ hideAll()
129
+ return
130
+ }
131
+
132
+ if (!tableDOMNode) return
133
+
134
+ tableCellDOMNodeRef.current = tableDOMNode
135
+
136
+ let hoveredRowNode = null
137
+ let hoveredColumnNode = null
138
+ let tableDOMElement = null
139
+ let canDeleteRow = false
140
+ let canDeleteColumn = false
141
+
142
+ editor.getEditorState().read(
143
+ () => {
144
+ const maybeTableCell = $getNearestNodeFromDOMNode(tableDOMNode)
145
+
146
+ if ($isTableCellNode(maybeTableCell)) {
147
+ const table = $findMatchingParent(maybeTableCell, (node) => $isTableNode(node))
148
+ if (!$isTableNode(table)) return
149
+
150
+ tableDOMElement = getTableElement(table, editor.getElementByKey(table.getKey()))
151
+
152
+ if (tableDOMElement) {
153
+ const rowCount = table.getChildrenSize()
154
+ const colCount = table.getChildAtIndex(0)?.getChildrenSize()
155
+
156
+ const rowIndex = $getTableRowIndexFromTableCellNode(maybeTableCell)
157
+ const colIndex = $getTableColumnIndexFromTableCellNode(maybeTableCell)
158
+
159
+ if (rowIndex === rowCount - 1) {
160
+ hoveredRowNode = maybeTableCell
161
+ } else if (colIndex === colCount - 1) {
162
+ hoveredColumnNode = maybeTableCell
163
+ }
164
+
165
+ // Deleting the only row/column would leave a degenerate table, so
166
+ // the gutter "×" only appears when more than one remains.
167
+ canDeleteRow = rowCount > 1
168
+ canDeleteColumn = (colCount ?? 0) > 1
169
+ }
170
+ }
171
+ },
172
+ { editor }
173
+ )
174
+
175
+ if (!tableDOMElement) return
176
+
177
+ const {
178
+ width: tableElemWidth,
179
+ y: tableElemY,
180
+ right: tableElemRight,
181
+ left: tableElemLeft,
182
+ bottom: tableElemBottom,
183
+ height: tableElemHeight
184
+ } = tableDOMElement.getBoundingClientRect()
185
+
186
+ const parentElement = tableDOMElement.parentElement
187
+ let tableHasScroll = false
188
+ if (parentElement && parentElement.classList.contains("lexical-table-wrapper")) {
189
+ tableHasScroll = parentElement.scrollWidth > parentElement.clientWidth
190
+ }
191
+ const { y: editorElemY, left: editorElemLeft } = anchorElem.getBoundingClientRect()
192
+
193
+ // ----- Append "+" bars (last row / last column), unchanged behaviour. -----
194
+ if (hoveredRowNode) {
195
+ const isMac = /^mac/i.test(navigator.platform)
196
+
197
+ setShownColumn(false)
198
+ setShownRow(true)
199
+ setPosition({
200
+ height: BUTTON_WIDTH_PX,
201
+ left:
202
+ tableHasScroll && parentElement
203
+ ? parentElement.offsetLeft
204
+ : tableElemLeft - editorElemLeft,
205
+ top: tableElemBottom - editorElemY + (tableHasScroll && !isMac ? 16 : 5),
206
+ width:
207
+ tableHasScroll && parentElement ? parentElement.offsetWidth : tableElemWidth
208
+ })
209
+ } else if (hoveredColumnNode) {
210
+ setShownColumn(true)
211
+ setShownRow(false)
212
+ setPosition({
213
+ height: tableElemHeight,
214
+ left: tableElemRight - editorElemLeft + 5,
215
+ top: tableElemY - editorElemY,
216
+ width: BUTTON_WIDTH_PX
217
+ })
218
+ } else {
219
+ setShownRow(false)
220
+ setShownColumn(false)
221
+ }
222
+
223
+ // ----- Delete "×" buttons for the hovered cell's own row and column. -----
224
+ const cellRect = tableDOMNode.getBoundingClientRect()
225
+
226
+ if (canDeleteRow) {
227
+ // Vertical strip in the row's left gutter, just outside the table edge.
228
+ setShownDeleteRow(true)
229
+ setDeleteRowPosition({
230
+ top: cellRect.top - editorElemY,
231
+ height: cellRect.height,
232
+ left: Math.max(0, tableElemLeft - editorElemLeft - DELETE_BUTTON_PX - 4),
233
+ width: DELETE_BUTTON_PX
234
+ })
235
+ } else {
236
+ setShownDeleteRow(false)
237
+ }
238
+
239
+ if (canDeleteColumn) {
240
+ // Horizontal strip in the column's top gutter, just above the table edge.
241
+ setShownDeleteColumn(true)
242
+ setDeleteColumnPosition({
243
+ left: cellRect.left - editorElemLeft,
244
+ width: cellRect.width,
245
+ top: Math.max(0, tableElemY - editorElemY - DELETE_BUTTON_PX - 4),
246
+ height: DELETE_BUTTON_PX
247
+ })
248
+ } else {
249
+ setShownDeleteColumn(false)
250
+ }
251
+ },
252
+ 50,
253
+ 250
254
+ )
255
+
256
+ // Hide the buttons whenever a table resizes so a button can't end up overlapping
257
+ // a row/column after text entry grows a cell.
258
+ const tableResizeObserver = useMemo(() => {
259
+ return new ResizeObserver(() => {
260
+ hideAll()
261
+ })
262
+ }, [])
263
+
264
+ useEffect(() => {
265
+ if (!shouldListenMouseMove) return
266
+
267
+ document.addEventListener("mousemove", debouncedOnMouseMove)
268
+
269
+ return () => {
270
+ hideAll()
271
+ debouncedOnMouseMove.cancel()
272
+ document.removeEventListener("mousemove", debouncedOnMouseMove)
273
+ }
274
+ }, [shouldListenMouseMove, debouncedOnMouseMove])
275
+
276
+ useEffect(() => {
277
+ const unregister = mergeRegister(
278
+ editor.registerMutationListener(
279
+ TableNode,
280
+ (mutations) => {
281
+ editor.getEditorState().read(
282
+ () => {
283
+ let resetObserver = false
284
+ for (const [key, type] of mutations) {
285
+ if (type === "created") {
286
+ tableSetRef.current.add(key)
287
+ resetObserver = true
288
+ } else if (type === "destroyed") {
289
+ tableSetRef.current.delete(key)
290
+ resetObserver = true
291
+ }
292
+ }
293
+ if (resetObserver) {
294
+ tableResizeObserver.disconnect()
295
+ for (const tableKey of tableSetRef.current) {
296
+ const { tableElement } = $getTableAndElementByKey(tableKey)
297
+ // Guard: the key may be destroyed between the mutation batch
298
+ // and this read, leaving no element to observe.
299
+ if (tableElement) tableResizeObserver.observe(tableElement)
300
+ }
301
+ setShouldListenMouseMove(tableSetRef.current.size > 0)
302
+ }
303
+ },
304
+ { editor }
305
+ )
306
+ },
307
+ { skipInitialization: false }
308
+ )
309
+ )
310
+ // Disconnect the observer on unmount; mergeRegister only unregisters the
311
+ // mutation listener, so without this the observer leaks table DOM refs
312
+ // across editor open/close cycles.
313
+ return () => {
314
+ unregister()
315
+ tableResizeObserver.disconnect()
316
+ }
317
+ }, [editor, tableResizeObserver])
318
+
319
+ const insertAction = (insertRow) => {
320
+ editor.update(() => {
321
+ if (tableCellDOMNodeRef.current) {
322
+ const maybeTableNode = $getNearestNodeFromDOMNode(tableCellDOMNodeRef.current)
323
+ maybeTableNode?.selectEnd()
324
+ if (insertRow) {
325
+ $insertTableRowAtSelection()
326
+ setShownRow(false)
327
+ } else {
328
+ $insertTableColumnAtSelection()
329
+ setShownColumn(false)
330
+ }
331
+ }
332
+ })
333
+ }
334
+
335
+ const deleteAction = (deleteRow) => {
336
+ editor.update(() => {
337
+ if (tableCellDOMNodeRef.current) {
338
+ const maybeTableCell = $getNearestNodeFromDOMNode(tableCellDOMNodeRef.current)
339
+ if ($isTableCellNode(maybeTableCell)) {
340
+ // Anchor the selection in the hovered cell so the delete targets its
341
+ // row/column (mousemove never moved the editor selection there).
342
+ maybeTableCell.selectEnd()
343
+ if (deleteRow) {
344
+ $deleteTableRowAtSelection()
345
+ setShownDeleteRow(false)
346
+ } else {
347
+ $deleteTableColumnAtSelection()
348
+ setShownDeleteColumn(false)
349
+ }
350
+ }
351
+ }
352
+ })
353
+ }
354
+
355
+ if (!isEditable) return null
356
+
357
+ return (
358
+ <>
359
+ {isShownRow && (
360
+ <button
361
+ type="button"
362
+ aria-label="Add row"
363
+ className={`${getTheme()?.tableAddRows}`}
364
+ style={{ ...position }}
365
+ onClick={() => insertAction(true)}
366
+ />
367
+ )}
368
+ {isShownColumn && (
369
+ <button
370
+ type="button"
371
+ aria-label="Add column"
372
+ className={`${getTheme()?.tableAddColumns}`}
373
+ style={{ ...position }}
374
+ onClick={() => insertAction(false)}
375
+ />
376
+ )}
377
+ {isShownDeleteRow && (
378
+ <button
379
+ type="button"
380
+ aria-label="Delete row"
381
+ className={`${getTheme()?.tableDeleteRows}`}
382
+ style={{ ...deleteRowPosition }}
383
+ onClick={() => deleteAction(true)}
384
+ />
385
+ )}
386
+ {isShownDeleteColumn && (
387
+ <button
388
+ type="button"
389
+ aria-label="Delete column"
390
+ className={`${getTheme()?.tableDeleteColumns}`}
391
+ style={{ ...deleteColumnPosition }}
392
+ onClick={() => deleteAction(false)}
393
+ />
394
+ )}
395
+ </>
396
+ )
397
+ }
398
+
399
+ export default function TableHoverActionsPlugin({ anchorElem = document.body } = {}) {
400
+ const isEditable = useLexicalEditable()
401
+
402
+ return isEditable
403
+ ? createPortal(<TableHoverActionsContainer anchorElem={anchorElem} />, anchorElem)
404
+ : null
405
+ }