collavre 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +73 -71
- data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
- data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
- data/app/assets/stylesheets/collavre/creatives.css +101 -51
- data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
- data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
- data/app/assets/stylesheets/collavre/popup.css +57 -27
- data/app/assets/stylesheets/collavre/slide_view.css +6 -6
- data/app/assets/stylesheets/collavre/user_menu.css +4 -5
- data/app/components/collavre/plans_timeline_component.html.erb +2 -2
- data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
- data/app/controllers/collavre/admin/settings_controller.rb +199 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
- data/app/controllers/collavre/comments_controller.rb +39 -162
- data/app/controllers/collavre/creatives_controller.rb +18 -58
- data/app/controllers/collavre/users_controller.rb +31 -3
- data/app/helpers/collavre/application_helper.rb +97 -0
- data/app/helpers/collavre/creatives_helper.rb +10 -202
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/components/creative_tree_row.js +3 -2
- data/app/javascript/controllers/comment_controller.js +309 -4
- data/app/javascript/controllers/comments/form_controller.js +52 -0
- data/app/javascript/controllers/comments/presence_controller.js +13 -0
- data/app/javascript/controllers/creatives/tree_controller.js +2 -1
- data/app/javascript/controllers/link_creative_controller.js +29 -3
- data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
- data/app/javascript/lib/html_code_block_wrapper.js +168 -0
- data/app/javascript/lib/utils/markdown.js +2 -1
- data/app/javascript/modules/creative_row_editor.js +5 -1
- data/app/javascript/utils/emoji_parser.js +21 -0
- data/app/jobs/collavre/ai_agent_job.rb +6 -2
- data/app/jobs/collavre/cron_action_job.rb +18 -6
- data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
- data/app/models/collavre/comment/approvable.rb +50 -0
- data/app/models/collavre/comment/broadcastable.rb +119 -0
- data/app/models/collavre/comment/notifiable.rb +111 -0
- data/app/models/collavre/comment.rb +13 -258
- data/app/models/collavre/comment_reaction.rb +15 -0
- data/app/models/collavre/creative/describable.rb +86 -0
- data/app/models/collavre/creative/linkable.rb +77 -0
- data/app/models/collavre/creative/permissible.rb +103 -0
- data/app/models/collavre/creative.rb +3 -289
- data/app/models/collavre/orchestrator_policy.rb +1 -1
- data/app/models/collavre/system_setting.rb +27 -1
- data/app/models/collavre/user.rb +42 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
- data/app/services/collavre/ai_agent/message_builder.rb +129 -0
- data/app/services/collavre/ai_agent/review_handler.rb +70 -0
- data/app/services/collavre/ai_agent_service.rb +93 -150
- data/app/services/collavre/ai_client.rb +23 -4
- data/app/services/collavre/auto_theme_generator.rb +168 -50
- data/app/services/collavre/command_menu_service.rb +70 -0
- data/app/services/collavre/comment_move_service.rb +94 -0
- data/app/services/collavre/comments/action_executor.rb +10 -0
- data/app/services/collavre/comments/mcp_command.rb +1 -2
- data/app/services/collavre/creatives/create_service.rb +86 -0
- data/app/services/collavre/creatives/destroy_service.rb +41 -0
- data/app/services/collavre/creatives/index_query.rb +3 -0
- data/app/services/collavre/markdown_converter.rb +240 -0
- data/app/services/collavre/mention_parser.rb +63 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
- data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
- data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
- data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
- data/app/services/collavre/orchestration/scheduler.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -6
- data/app/services/collavre/tools/creative_batch_service.rb +107 -0
- data/app/services/collavre/tools/creative_update_service.rb +17 -12
- data/app/services/collavre/tools/cron_create_service.rb +17 -5
- data/app/views/admin/shared/_tabs.html.erb +2 -1
- data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
- data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
- data/app/views/collavre/admin/settings/index.html.erb +11 -0
- data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +15 -5
- data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
- data/app/views/collavre/creatives/_share_button.html.erb +0 -52
- data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
- data/app/views/collavre/creatives/index.html.erb +5 -8
- data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
- data/app/views/collavre/user_themes/index.html.erb +7 -9
- data/app/views/collavre/users/_contact_management.html.erb +2 -1
- data/app/views/collavre/users/edit_ai.html.erb +7 -0
- data/app/views/collavre/users/index.html.erb +16 -1
- data/app/views/collavre/users/new_ai.html.erb +18 -8
- data/app/views/collavre/users/passkeys.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +8 -1
- data/config/locales/admin.en.yml +88 -0
- data/config/locales/admin.ko.yml +88 -0
- data/config/locales/ai_agent.en.yml +5 -1
- data/config/locales/ai_agent.ko.yml +5 -1
- data/config/locales/comments.en.yml +5 -1
- data/config/locales/comments.ko.yml +5 -1
- data/config/locales/orchestration.en.yml +8 -0
- data/config/locales/orchestration.ko.yml +8 -0
- data/config/locales/users.en.yml +12 -0
- data/config/locales/users.ko.yml +12 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
- data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
- data/lib/collavre/engine.rb +25 -0
- data/lib/collavre/version.rb +1 -1
- metadata +32 -1
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
require "base64"
|
|
2
|
-
require "securerandom"
|
|
3
|
-
require "nokogiri"
|
|
4
|
-
|
|
5
1
|
module Collavre
|
|
6
2
|
module CreativesHelper
|
|
7
3
|
# Shared toggle button symbol helper
|
|
@@ -93,7 +89,7 @@ module Collavre
|
|
|
93
89
|
!!(expanded_state_map && expanded_state_map[creative_id.to_s])
|
|
94
90
|
end
|
|
95
91
|
|
|
96
|
-
def render_creative_tree_markdown(creatives, level = 1, with_progress = false)
|
|
92
|
+
def render_creative_tree_markdown(creatives, level = 1, with_progress = false, max_depth: nil)
|
|
97
93
|
return "" if creatives.blank?
|
|
98
94
|
md = ""
|
|
99
95
|
creatives.each do |creative|
|
|
@@ -103,18 +99,18 @@ module Collavre
|
|
|
103
99
|
desc = "#{desc} (#{pct}%)"
|
|
104
100
|
end
|
|
105
101
|
raw_html = desc.gsub(/<!--.*?-->/m, "").strip
|
|
106
|
-
markdown_content =
|
|
102
|
+
markdown_content = MarkdownConverter.html_to_markdown(raw_html)
|
|
107
103
|
cleaned_markdown = markdown_content.strip
|
|
108
104
|
rendered_table_block = false
|
|
109
105
|
|
|
110
106
|
table_match = cleaned_markdown.match(/^<div[^>]*>\s*<div[^>]*>\s*(\|.*?\|(?:\n\|.*?\|)*)\s*<\/div>\s*<\/div>$/m)
|
|
111
107
|
if level <= 4 && table_match
|
|
112
108
|
table_content = table_match[1].strip
|
|
113
|
-
if
|
|
109
|
+
if MarkdownConverter.table_block?(table_content)
|
|
114
110
|
md += "#{table_content}\n\n"
|
|
115
111
|
rendered_table_block = true
|
|
116
112
|
end
|
|
117
|
-
elsif level <= 4 &&
|
|
113
|
+
elsif level <= 4 && MarkdownConverter.table_block?(cleaned_markdown)
|
|
118
114
|
md += "#{cleaned_markdown}\n\n"
|
|
119
115
|
rendered_table_block = true
|
|
120
116
|
elsif level <= 4
|
|
@@ -134,210 +130,22 @@ module Collavre
|
|
|
134
130
|
md += "#{indent}* #{inner}\n"
|
|
135
131
|
end
|
|
136
132
|
children = creative.linked_children
|
|
137
|
-
if children.present?
|
|
138
|
-
md += render_creative_tree_markdown(children, level + 1, with_progress)
|
|
133
|
+
if children.present? && (max_depth.nil? || level < max_depth)
|
|
134
|
+
md += render_creative_tree_markdown(children, level + 1, with_progress, max_depth: max_depth)
|
|
139
135
|
end
|
|
140
136
|
md += "\n" if level <= 4 && !rendered_table_block
|
|
141
137
|
end
|
|
142
138
|
md
|
|
143
139
|
end
|
|
144
140
|
|
|
141
|
+
# Delegate to MarkdownConverter for backward compatibility.
|
|
142
|
+
# These methods are used in views and by MarkdownImporter via ApplicationController.helpers.
|
|
145
143
|
def markdown_links_to_html(text, image_refs = {})
|
|
146
|
-
|
|
147
|
-
html = text.dup
|
|
148
|
-
|
|
149
|
-
html.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
|
|
150
|
-
image_refs[$1] = $2.strip
|
|
151
|
-
""
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
html.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
|
|
155
|
-
if (data_url = image_refs[$2])
|
|
156
|
-
convert_data_image_to_attachment(data_url, $1)
|
|
157
|
-
else
|
|
158
|
-
"![#{$1}][#{$2}]"
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
html.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
|
|
163
|
-
convert_data_image_to_attachment($2, $1)
|
|
164
|
-
end
|
|
165
|
-
html.gsub!(/(?<!\\)\[([^\]]+)\]\(([^)]+)\)/) do
|
|
166
|
-
"<a href=\"#{$2}\">#{$1}</a>"
|
|
167
|
-
end
|
|
168
|
-
html.gsub!(/(?<!\\)(\*\*|__)(.+?)\1/m) do
|
|
169
|
-
"<strong>#{$2}</strong>"
|
|
170
|
-
end
|
|
171
|
-
html.gsub!(/\\([\\*_\[\]()!#~+\-])/, '\\1')
|
|
172
|
-
html.gsub!(/\\\\/, "\\")
|
|
173
|
-
html.strip!
|
|
174
|
-
html
|
|
144
|
+
MarkdownConverter.markdown_to_html(text, image_refs)
|
|
175
145
|
end
|
|
176
146
|
|
|
177
147
|
def html_links_to_markdown(text)
|
|
178
|
-
|
|
179
|
-
markdown = text.dup
|
|
180
|
-
placeholders = {}
|
|
181
|
-
index = 0
|
|
182
|
-
markdown.gsub!(%r{<table\b[^>]*>.*?</table>}im) do |match|
|
|
183
|
-
token = "__TABLE#{index}__"; index += 1
|
|
184
|
-
placeholders[token] = html_table_to_markdown(match)
|
|
185
|
-
token
|
|
186
|
-
end
|
|
187
|
-
markdown.gsub!(%r{<action-text-attachment ([^>]+)>(?:</action-text-attachment>)?}) do |match|
|
|
188
|
-
attrs = Hash[$1.scan(/(\S+?)="([^"]*)"/)]
|
|
189
|
-
sgid = attrs["sgid"]
|
|
190
|
-
caption = attrs["caption"] || ""
|
|
191
|
-
if (blob = GlobalID::Locator.locate_signed(sgid, for: "attachable"))
|
|
192
|
-
data = Base64.strict_encode64(blob.download)
|
|
193
|
-
token = "__IMG#{index}__"; index += 1
|
|
194
|
-
placeholders[token] = ""
|
|
195
|
-
token
|
|
196
|
-
else
|
|
197
|
-
""
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
markdown.gsub!(%r{<img [^>]*src=["'](/rails/active_storage/blobs/[^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*>}) do |match|
|
|
201
|
-
blob_path = $1
|
|
202
|
-
alt_text = $2
|
|
203
|
-
if blob_path =~ %r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/]+)/}
|
|
204
|
-
signed_id = $1
|
|
205
|
-
begin
|
|
206
|
-
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
207
|
-
data = Base64.strict_encode64(blob.download)
|
|
208
|
-
token = "__IMG#{index}__"; index += 1
|
|
209
|
-
placeholders[token] = ""
|
|
210
|
-
token
|
|
211
|
-
rescue
|
|
212
|
-
match
|
|
213
|
-
end
|
|
214
|
-
else
|
|
215
|
-
match
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
markdown.gsub!(/<img [^>]*src=['"](data:[^'"]+)['"][^>]*alt=['"]([^'"]*)['"][^>]*>/) do
|
|
219
|
-
token = "__IMG#{index}__"; index += 1
|
|
220
|
-
placeholders[token] = ""
|
|
221
|
-
token
|
|
222
|
-
end
|
|
223
|
-
markdown.gsub!(/<img [^>]*alt=['"]([^'"]*)['"][^>]*src=['"](data:[^'"]+)['"][^>]*>/) do
|
|
224
|
-
token = "__IMG#{index}__"; index += 1
|
|
225
|
-
placeholders[token] = ""
|
|
226
|
-
token
|
|
227
|
-
end
|
|
228
|
-
markdown.gsub!(/<a [^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/m) do
|
|
229
|
-
inner = ActionView::Base.full_sanitizer.sanitize($2)
|
|
230
|
-
token = "__LINK#{index}__"; index += 1
|
|
231
|
-
placeholders[token] = "[#{inner}](#{$1})"
|
|
232
|
-
token
|
|
233
|
-
end
|
|
234
|
-
markdown.gsub!(/<(strong|b)(?:\s+[^>]*)?>(.*?)<\/\1>/im) do
|
|
235
|
-
token = "__BOLD#{index}__"; index += 1
|
|
236
|
-
placeholders[token] = "**#{$2.strip}**"
|
|
237
|
-
token
|
|
238
|
-
end
|
|
239
|
-
markdown.gsub!(/([\\*\[\]()!#~+\-])/) { "\\#{$1}" }
|
|
240
|
-
placeholders.each { |k, v| markdown.gsub!(k, v) }
|
|
241
|
-
markdown.gsub!(/<[^>]+>/, "")
|
|
242
|
-
markdown
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
private
|
|
246
|
-
|
|
247
|
-
def markdown_table_block?(text)
|
|
248
|
-
lines = text.to_s.strip.split("\n")
|
|
249
|
-
return false if lines.length < 2
|
|
250
|
-
|
|
251
|
-
header_line = lines[0]
|
|
252
|
-
alignment_line = lines[1]
|
|
253
|
-
return false unless header_line.match?(/\A\|.*\|\z/)
|
|
254
|
-
return false unless alignment_line.match?(/\A\|[ \-:\|]+\|\z/)
|
|
255
|
-
|
|
256
|
-
true
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def convert_data_image_to_attachment(data_url, alt)
|
|
260
|
-
if data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
|
|
261
|
-
content_type = Regexp.last_match(1)
|
|
262
|
-
data = Base64.decode64(Regexp.last_match(2))
|
|
263
|
-
ext = Mime::Type.lookup(content_type).symbol.to_s
|
|
264
|
-
filename = "import-#{SecureRandom.hex}.#{ext}"
|
|
265
|
-
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
266
|
-
"<img src=\"#{Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)}\" alt=\"#{alt}\" />"
|
|
267
|
-
else
|
|
268
|
-
"<img src=\"#{data_url}\" alt=\"#{alt}\" />"
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def html_table_to_markdown(table_html)
|
|
273
|
-
fragment = Nokogiri::HTML::DocumentFragment.parse(table_html)
|
|
274
|
-
table = fragment.at_css("table")
|
|
275
|
-
return "" unless table
|
|
276
|
-
|
|
277
|
-
header_row = table.at_css("thead tr") || table.css("tr").first
|
|
278
|
-
return "" unless header_row
|
|
279
|
-
|
|
280
|
-
header_cells = header_row.css("th,td")
|
|
281
|
-
headers = header_cells.map { |cell| escape_markdown_table_cell(html_links_to_markdown(cell.inner_html).strip) }
|
|
282
|
-
|
|
283
|
-
alignments = header_cells.map { |cell| alignment_from_html_cell(cell) }
|
|
284
|
-
|
|
285
|
-
body_rows = table.css("tbody tr")
|
|
286
|
-
if body_rows.empty?
|
|
287
|
-
all_rows = table.css("tr")
|
|
288
|
-
body_rows = all_rows.drop(1)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
body_lines = body_rows.map do |row|
|
|
292
|
-
cells = row.css("th,td").map { |cell| escape_markdown_table_cell(html_links_to_markdown(cell.inner_html).strip) }
|
|
293
|
-
normalized = normalize_row_cells(cells, headers.length)
|
|
294
|
-
"| #{normalized.join(' | ')} |"
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
alignment_cells = normalize_row_cells(alignments, headers.length).map { |align| alignment_to_markdown(align) }
|
|
298
|
-
header_line = "| #{headers.map(&:strip).join(' | ')} |"
|
|
299
|
-
alignment_line = "| #{alignment_cells.join(' | ')} |"
|
|
300
|
-
|
|
301
|
-
([ header_line, alignment_line ] + body_lines).join("\n")
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def escape_markdown_table_cell(text)
|
|
305
|
-
text.to_s.gsub(/(?<!\\)\|/, '\\|')
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def alignment_from_html_cell(cell)
|
|
309
|
-
style = cell["style"].to_s
|
|
310
|
-
align = cell["align"].to_s
|
|
311
|
-
case
|
|
312
|
-
when style =~ /text-align\s*:\s*center/i || align =~ /center/i
|
|
313
|
-
:center
|
|
314
|
-
when style =~ /text-align\s*:\s*right/i || align =~ /right/i
|
|
315
|
-
:right
|
|
316
|
-
when style =~ /text-align\s*:\s*left/i || align =~ /left/i
|
|
317
|
-
:left
|
|
318
|
-
else
|
|
319
|
-
nil
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def alignment_to_markdown(alignment)
|
|
324
|
-
case alignment
|
|
325
|
-
when :center
|
|
326
|
-
":---:"
|
|
327
|
-
when :right
|
|
328
|
-
"---:"
|
|
329
|
-
when :left
|
|
330
|
-
":---"
|
|
331
|
-
else
|
|
332
|
-
"---"
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
def normalize_row_cells(cells, expected_length)
|
|
337
|
-
values = cells.dup
|
|
338
|
-
values = values.first(expected_length)
|
|
339
|
-
values.fill("", values.length...expected_length)
|
|
340
|
-
values
|
|
148
|
+
MarkdownConverter.html_to_markdown(text)
|
|
341
149
|
end
|
|
342
150
|
end
|
|
343
151
|
end
|
data/app/javascript/collavre.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LitElement, html, nothing } from "lit";
|
|
2
2
|
import DOMPurify from "dompurify";
|
|
3
3
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
4
|
+
import { parseEmojis } from "../utils/emoji_parser";
|
|
4
5
|
|
|
5
6
|
const BULLET_STARTING_LEVEL = 3;
|
|
6
7
|
|
|
@@ -303,7 +304,7 @@ class CreativeTreeRow extends LitElement {
|
|
|
303
304
|
|
|
304
305
|
// If no children, render as div instead of heading to avoid large font size
|
|
305
306
|
if (!this.hasChildren) {
|
|
306
|
-
return html`<div class
|
|
307
|
+
return html`<div class="${headingClass} creative-childless">${content}${indicator}</div>`;
|
|
307
308
|
}
|
|
308
309
|
|
|
309
310
|
// We wrap content in heading tags for semantics
|
|
@@ -361,7 +362,7 @@ class CreativeTreeRow extends LitElement {
|
|
|
361
362
|
emojiString = rootStyle.getPropertyValue('--creative-loading-emojis').replace(/"/g, '').trim();
|
|
362
363
|
}
|
|
363
364
|
|
|
364
|
-
const emojis = emojiString
|
|
365
|
+
const emojis = parseEmojis(emojiString);
|
|
365
366
|
|
|
366
367
|
let emojiIndex = 0;
|
|
367
368
|
let frame = 0;
|
|
@@ -1,18 +1,132 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { renderCommentMarkdown } from '../lib/utils/markdown'
|
|
3
|
+
import CommonPopup from '../lib/common_popup'
|
|
4
|
+
|
|
5
|
+
// Global tracker: persists streaming state across Turbo replacements
|
|
6
|
+
// (each replacement creates a new controller instance, losing instance state)
|
|
7
|
+
if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
|
|
3
8
|
|
|
4
9
|
// Connects to data-controller="comment"
|
|
5
10
|
export default class extends Controller {
|
|
6
|
-
static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
|
|
11
|
+
static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls", "reviewButton", "replaceButton"]
|
|
12
|
+
|
|
13
|
+
get _commentId() {
|
|
14
|
+
return this.element.dataset.commentId
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get _isStreaming() {
|
|
18
|
+
return window._streamingCommentIds.has(this._commentId)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
set _isStreaming(val) {
|
|
22
|
+
if (val) {
|
|
23
|
+
window._streamingCommentIds.add(this._commentId)
|
|
24
|
+
} else {
|
|
25
|
+
window._streamingCommentIds.delete(this._commentId)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Streaming state helpers ---
|
|
30
|
+
|
|
31
|
+
get _isAiComment() {
|
|
32
|
+
return this.element.dataset.aiUser === 'true'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Server explicitly marked this broadcast as streaming (true) or done (false).
|
|
36
|
+
// When no streaming local is provided the partial defaults to "false".
|
|
37
|
+
get _serverStreaming() {
|
|
38
|
+
return this.element.dataset.streaming === 'true'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The server sent streaming: false explicitly — stream just ended.
|
|
42
|
+
get _serverStreamingDone() {
|
|
43
|
+
return this.element.dataset.streaming === 'false'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Should we treat this comment as currently streaming?
|
|
47
|
+
// True when the server says so OR the global Set remembers it,
|
|
48
|
+
// but NOT if the server explicitly signalled completion.
|
|
49
|
+
_shouldStream(text) {
|
|
50
|
+
if (!this._isAiComment || !text.trim()) return false
|
|
51
|
+
if (this._serverStreamingDone) return false
|
|
52
|
+
return this._isStreaming || this._serverStreaming
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_appendStreamingCursor(el) {
|
|
56
|
+
const cursor = document.createElement('span')
|
|
57
|
+
cursor.className = 'streaming-cursor'
|
|
58
|
+
cursor.textContent = '▍'
|
|
59
|
+
let target = el
|
|
60
|
+
while (target.lastElementChild && target.lastElementChild.tagName !== 'BR') {
|
|
61
|
+
target = target.lastElementChild
|
|
62
|
+
}
|
|
63
|
+
target.appendChild(cursor)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_resetStreamingTimeout() {
|
|
67
|
+
if (this._streamingTimeout) clearTimeout(this._streamingTimeout)
|
|
68
|
+
this._streamingTimeout = setTimeout(() => {
|
|
69
|
+
const currentEl = this.element?.querySelector('.comment-content')
|
|
70
|
+
if (currentEl) {
|
|
71
|
+
currentEl.dataset.rendering = 'true'
|
|
72
|
+
try {
|
|
73
|
+
currentEl.classList.remove('streaming')
|
|
74
|
+
const c = currentEl.querySelector('.streaming-cursor')
|
|
75
|
+
if (c) c.remove()
|
|
76
|
+
} finally {
|
|
77
|
+
requestAnimationFrame(() => { currentEl.dataset.rendering = 'false' })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this._isStreaming = false
|
|
81
|
+
}, 10000)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_cleanupStreaming() {
|
|
85
|
+
this._isStreaming = false
|
|
86
|
+
if (this._streamingTimeout) {
|
|
87
|
+
clearTimeout(this._streamingTimeout)
|
|
88
|
+
this._streamingTimeout = null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
7
91
|
|
|
8
92
|
connect() {
|
|
93
|
+
if (!this._streamingTimeout) this._streamingTimeout = null
|
|
9
94
|
const contentElement = this.element.querySelector('.comment-content')
|
|
10
95
|
if (contentElement && contentElement.dataset.rendered !== 'true') {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
96
|
+
contentElement.dataset.rendering = 'true'
|
|
97
|
+
try {
|
|
98
|
+
const text = contentElement.textContent || ''
|
|
99
|
+
if (this._isAiComment && text.trim() === '...') {
|
|
100
|
+
// Streaming placeholder — show animated dots
|
|
101
|
+
this._isStreaming = true
|
|
102
|
+
contentElement.innerHTML = '<span class="streaming-dots"><span>.</span><span>.</span><span>.</span></span>'
|
|
103
|
+
contentElement.classList.add('streaming')
|
|
104
|
+
} else if (this._shouldStream(text)) {
|
|
105
|
+
// Streaming content arrived — render markdown with cursor
|
|
106
|
+
this._isStreaming = true
|
|
107
|
+
contentElement.innerHTML = renderCommentMarkdown(text)
|
|
108
|
+
this._appendStreamingCursor(contentElement)
|
|
109
|
+
contentElement.classList.add('streaming')
|
|
110
|
+
this._resetStreamingTimeout()
|
|
111
|
+
} else {
|
|
112
|
+
contentElement.innerHTML = renderCommentMarkdown(text)
|
|
113
|
+
contentElement.classList.remove('streaming')
|
|
114
|
+
if (this._isStreaming) this._cleanupStreaming()
|
|
115
|
+
}
|
|
116
|
+
contentElement.dataset.rendered = 'true'
|
|
117
|
+
} finally {
|
|
118
|
+
requestAnimationFrame(() => { contentElement.dataset.rendering = 'false' })
|
|
119
|
+
}
|
|
14
120
|
}
|
|
15
121
|
|
|
122
|
+
// Text selection quote support
|
|
123
|
+
this.handleMouseUp = this.handleMouseUp.bind(this)
|
|
124
|
+
this.element.addEventListener('mouseup', this.handleMouseUp)
|
|
125
|
+
|
|
126
|
+
// Bound handlers for review/replace buttons (stored for cleanup)
|
|
127
|
+
this._boundReviewClick = this._onReviewClick.bind(this)
|
|
128
|
+
this._boundReplaceClick = this._onReplaceClick.bind(this)
|
|
129
|
+
|
|
16
130
|
this.currentUserId = document.body.dataset.currentUserId
|
|
17
131
|
const commentAuthorId = this.element.dataset.userId
|
|
18
132
|
const creativeOwnerId = this.element.dataset.creativeOwnerId
|
|
@@ -108,6 +222,197 @@ export default class extends Controller {
|
|
|
108
222
|
}
|
|
109
223
|
}
|
|
110
224
|
|
|
225
|
+
disconnect() {
|
|
226
|
+
if (this._streamingTimeout) {
|
|
227
|
+
clearTimeout(this._streamingTimeout)
|
|
228
|
+
this._streamingTimeout = null
|
|
229
|
+
}
|
|
230
|
+
this.element.removeEventListener('mouseup', this.handleMouseUp)
|
|
231
|
+
this._removeSelectionChangeListener()
|
|
232
|
+
this.hideReviewPopup()
|
|
233
|
+
if (this._reviewPopupEl) {
|
|
234
|
+
this._reviewPopupEl.remove()
|
|
235
|
+
this._reviewPopupEl = null
|
|
236
|
+
this._reviewPopup = null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handleMouseUp() {
|
|
241
|
+
// Small delay to let selection finalize
|
|
242
|
+
requestAnimationFrame(() => {
|
|
243
|
+
this.hideReviewPopup()
|
|
244
|
+
|
|
245
|
+
const selection = window.getSelection()
|
|
246
|
+
if (!selection || selection.isCollapsed) return
|
|
247
|
+
|
|
248
|
+
const selectedText = selection.toString().trim()
|
|
249
|
+
if (!selectedText) return
|
|
250
|
+
|
|
251
|
+
// Ensure selection is within this comment's content
|
|
252
|
+
const contentEl = this.element.querySelector('.comment-content')
|
|
253
|
+
if (!contentEl) return
|
|
254
|
+
|
|
255
|
+
const range = selection.getRangeAt(0)
|
|
256
|
+
if (!contentEl.contains(range.commonAncestorContainer)) return
|
|
257
|
+
|
|
258
|
+
// Show review popup below the selection using CommonPopup
|
|
259
|
+
const rect = range.getBoundingClientRect()
|
|
260
|
+
this.showReviewPopup(rect, selectedText)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
showReviewPopup(anchorRect, selectedText) {
|
|
265
|
+
// Create or reuse popup element
|
|
266
|
+
if (!this._reviewPopupEl) {
|
|
267
|
+
const el = document.createElement('div')
|
|
268
|
+
el.className = 'common-popup comment-review-popup'
|
|
269
|
+
el.style.display = 'none'
|
|
270
|
+
el.style.padding = '0.3em'
|
|
271
|
+
|
|
272
|
+
const list = document.createElement('ul')
|
|
273
|
+
list.style.listStyle = 'none'
|
|
274
|
+
list.style.margin = '0'
|
|
275
|
+
list.style.padding = '0'
|
|
276
|
+
el.appendChild(list)
|
|
277
|
+
|
|
278
|
+
const commentsPopup = this.element.closest('#comments-popup')
|
|
279
|
+
if (commentsPopup) {
|
|
280
|
+
commentsPopup.appendChild(el)
|
|
281
|
+
} else {
|
|
282
|
+
document.body.appendChild(el)
|
|
283
|
+
}
|
|
284
|
+
this._reviewPopupEl = el
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const reviewLabel = this.element.closest('#comments-popup')?.dataset?.reviewButtonText || 'Review'
|
|
288
|
+
|
|
289
|
+
this._reviewPopup = new CommonPopup(this._reviewPopupEl, {
|
|
290
|
+
onSelect: () => {
|
|
291
|
+
const commentId = this.element.dataset.commentId
|
|
292
|
+
const formController = this.findFormController()
|
|
293
|
+
if (formController) {
|
|
294
|
+
formController.quoteComment(commentId, selectedText)
|
|
295
|
+
}
|
|
296
|
+
window.getSelection().removeAllRanges()
|
|
297
|
+
this.hideReviewPopup()
|
|
298
|
+
},
|
|
299
|
+
renderItem: () => reviewLabel,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// Position below the selection end
|
|
303
|
+
const belowRect = {
|
|
304
|
+
left: anchorRect.left,
|
|
305
|
+
right: anchorRect.right,
|
|
306
|
+
top: anchorRect.bottom,
|
|
307
|
+
bottom: anchorRect.bottom + 4,
|
|
308
|
+
width: anchorRect.width,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this._reviewPopup.setItems([{ label: reviewLabel }])
|
|
312
|
+
this._reviewPopup.showAt(belowRect)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
hideReviewPopup() {
|
|
316
|
+
if (this._reviewPopup) {
|
|
317
|
+
this._reviewPopup.hide()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
findFormController() {
|
|
322
|
+
const popup = this.element.closest('#comments-popup')
|
|
323
|
+
if (!popup) return null
|
|
324
|
+
return this.application.getControllerForElementAndIdentifier(popup, 'comments--form')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
reviewButtonTargetConnected(button) {
|
|
328
|
+
button.addEventListener('click', this._boundReviewClick)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
reviewButtonTargetDisconnected(button) {
|
|
332
|
+
button.removeEventListener('click', this._boundReviewClick)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
replaceButtonTargetConnected(button) {
|
|
336
|
+
button.addEventListener('click', this._boundReplaceClick)
|
|
337
|
+
// Only listen for selectionchange when a replace button exists
|
|
338
|
+
this._addSelectionChangeListener()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
replaceButtonTargetDisconnected(button) {
|
|
342
|
+
button.removeEventListener('click', this._boundReplaceClick)
|
|
343
|
+
if (!this.hasReplaceButtonTarget) {
|
|
344
|
+
this._removeSelectionChangeListener()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_onReviewClick(event) {
|
|
349
|
+
event.preventDefault()
|
|
350
|
+
event.stopPropagation()
|
|
351
|
+
const commentId = this.element.dataset.commentId
|
|
352
|
+
const contentEl = this.element.querySelector('.comment-content')
|
|
353
|
+
const fullText = contentEl ? contentEl.textContent.trim() : ''
|
|
354
|
+
const formController = this.findFormController()
|
|
355
|
+
if (formController && fullText) {
|
|
356
|
+
formController.quoteComment(commentId, fullText)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
_onReplaceClick(event) {
|
|
361
|
+
event.preventDefault()
|
|
362
|
+
event.stopPropagation()
|
|
363
|
+
const selectedText = this._getSelectedTextInContent()
|
|
364
|
+
if (!selectedText) return
|
|
365
|
+
|
|
366
|
+
const commentId = this.element.dataset.commentId
|
|
367
|
+
const formController = this.findFormController()
|
|
368
|
+
if (formController) {
|
|
369
|
+
formController.quoteComment(commentId, selectedText)
|
|
370
|
+
}
|
|
371
|
+
window.getSelection().removeAllRanges()
|
|
372
|
+
this._updateReplaceButton()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_getSelectedTextInContent() {
|
|
376
|
+
const selection = window.getSelection()
|
|
377
|
+
if (!selection || selection.isCollapsed) return null
|
|
378
|
+
|
|
379
|
+
const text = selection.toString().trim()
|
|
380
|
+
if (!text) return null
|
|
381
|
+
|
|
382
|
+
const contentEl = this.element.querySelector('.comment-content')
|
|
383
|
+
if (!contentEl) return null
|
|
384
|
+
|
|
385
|
+
const range = selection.getRangeAt(0)
|
|
386
|
+
if (!contentEl.contains(range.commonAncestorContainer)) return null
|
|
387
|
+
|
|
388
|
+
return text
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_addSelectionChangeListener() {
|
|
392
|
+
if (this._selectionChangeActive) return
|
|
393
|
+
this._handleSelectionChange = this._handleSelectionChange.bind(this)
|
|
394
|
+
document.addEventListener('selectionchange', this._handleSelectionChange)
|
|
395
|
+
this._selectionChangeActive = true
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_removeSelectionChangeListener() {
|
|
399
|
+
if (!this._selectionChangeActive) return
|
|
400
|
+
document.removeEventListener('selectionchange', this._handleSelectionChange)
|
|
401
|
+
this._selectionChangeActive = false
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_handleSelectionChange() {
|
|
405
|
+
this._updateReplaceButton()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_updateReplaceButton() {
|
|
409
|
+
if (!this.hasReplaceButtonTarget) return
|
|
410
|
+
const hasSelection = !!this._getSelectedTextInContent()
|
|
411
|
+
this.replaceButtonTargets.forEach((btn) => {
|
|
412
|
+
btn.disabled = !hasSelection
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
111
416
|
updateReactionsUI(reactionsData) {
|
|
112
417
|
let reactionsContainer = this.element.querySelector('.comment-reactions')
|
|
113
418
|
|