collavre 0.23.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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +42 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
|
|
7
|
+
const { default: SearchPopupController } = await import('../search_popup_controller')
|
|
8
|
+
|
|
9
|
+
describe('SearchPopupController Ctrl+K shortcut', () => {
|
|
10
|
+
let application
|
|
11
|
+
let container
|
|
12
|
+
let popup
|
|
13
|
+
|
|
14
|
+
function pressCtrlK(target) {
|
|
15
|
+
const event = new KeyboardEvent('keydown', {
|
|
16
|
+
key: 'k',
|
|
17
|
+
ctrlKey: true,
|
|
18
|
+
bubbles: true,
|
|
19
|
+
cancelable: true
|
|
20
|
+
})
|
|
21
|
+
target.dispatchEvent(event)
|
|
22
|
+
return event
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
document.body.innerHTML = ''
|
|
27
|
+
|
|
28
|
+
container = document.createElement('div')
|
|
29
|
+
container.innerHTML = `
|
|
30
|
+
<div data-controller="search-popup">
|
|
31
|
+
<input type="text" data-search-popup-target="input" />
|
|
32
|
+
<div data-search-popup-target="popup"></div>
|
|
33
|
+
</div>
|
|
34
|
+
`
|
|
35
|
+
document.body.appendChild(container)
|
|
36
|
+
|
|
37
|
+
application = Application.start()
|
|
38
|
+
application.register('search-popup', SearchPopupController)
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
40
|
+
|
|
41
|
+
popup = container.querySelector('[data-search-popup-target="popup"]')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
application?.stop()
|
|
46
|
+
document.body.innerHTML = ''
|
|
47
|
+
jest.restoreAllMocks()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('opens the popup on Ctrl+K when focus is not in an editable element', () => {
|
|
51
|
+
const event = pressCtrlK(document.body)
|
|
52
|
+
|
|
53
|
+
expect(popup.classList.contains('open')).toBe(true)
|
|
54
|
+
expect(event.defaultPrevented).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does not open the popup when Ctrl+K is pressed inside a text input', () => {
|
|
58
|
+
const input = document.createElement('input')
|
|
59
|
+
input.type = 'text'
|
|
60
|
+
document.body.appendChild(input)
|
|
61
|
+
input.focus()
|
|
62
|
+
|
|
63
|
+
const event = pressCtrlK(input)
|
|
64
|
+
|
|
65
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
66
|
+
expect(event.defaultPrevented).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('does not open the popup when Ctrl+K is pressed inside a textarea', () => {
|
|
70
|
+
const textarea = document.createElement('textarea')
|
|
71
|
+
document.body.appendChild(textarea)
|
|
72
|
+
textarea.focus()
|
|
73
|
+
|
|
74
|
+
const event = pressCtrlK(textarea)
|
|
75
|
+
|
|
76
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
77
|
+
expect(event.defaultPrevented).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('does not open the popup when Ctrl+K is pressed inside a contenteditable element', () => {
|
|
81
|
+
const editable = document.createElement('div')
|
|
82
|
+
editable.setAttribute('contenteditable', 'true')
|
|
83
|
+
document.body.appendChild(editable)
|
|
84
|
+
editable.focus()
|
|
85
|
+
|
|
86
|
+
const event = pressCtrlK(editable)
|
|
87
|
+
|
|
88
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
89
|
+
expect(event.defaultPrevented).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('does not open the popup when Ctrl+K is pressed inside the inline Lexical editor', () => {
|
|
93
|
+
const lexicalRoot = document.createElement('div')
|
|
94
|
+
lexicalRoot.setAttribute('data-lexical-editor-root', '')
|
|
95
|
+
const inner = document.createElement('p')
|
|
96
|
+
lexicalRoot.appendChild(inner)
|
|
97
|
+
document.body.appendChild(lexicalRoot)
|
|
98
|
+
|
|
99
|
+
const event = pressCtrlK(inner)
|
|
100
|
+
|
|
101
|
+
expect(popup.classList.contains('open')).toBe(false)
|
|
102
|
+
expect(event.defaultPrevented).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
import TopicListController from '../topic_list_controller'
|
|
7
|
+
|
|
8
|
+
describe('TopicListController', () => {
|
|
9
|
+
let application, controller
|
|
10
|
+
|
|
11
|
+
const mount = () => {
|
|
12
|
+
document.body.innerHTML = `
|
|
13
|
+
<div id="topic-list-modal" class="common-popup" data-controller="topic-list">
|
|
14
|
+
<button data-topic-list-target="close">×</button>
|
|
15
|
+
<input data-topic-list-target="input">
|
|
16
|
+
<ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
|
|
17
|
+
</div>
|
|
18
|
+
`
|
|
19
|
+
application = Application.start()
|
|
20
|
+
application.register('topic-list', TopicListController)
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
22
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
23
|
+
document.getElementById('topic-list-modal'), 'topic-list'
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RECT = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }
|
|
29
|
+
const DATA = {
|
|
30
|
+
topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
|
|
31
|
+
archivedTopics: [{ id: 3, name: 'Zeta' }],
|
|
32
|
+
mainTopicId: '1',
|
|
33
|
+
allMessagesLabel: 'All Messages'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const items = () => Array.from(document.querySelectorAll('#topic-list-modal li.common-popup-item'))
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
global.requestAnimationFrame = (fn) => { fn(); return 0 }
|
|
40
|
+
return mount()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
document.body.innerHTML = ''
|
|
45
|
+
application.stop()
|
|
46
|
+
jest.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('builds items in bar order: main, others, All Messages, archived', () => {
|
|
50
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
51
|
+
const labels = items().map((li) => li.textContent.trim())
|
|
52
|
+
expect(labels).toEqual(['#Main', '#Alpha', '📋 All Messages', '#Zeta'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('marks archived items with the distinguishing class', () => {
|
|
56
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
57
|
+
const archived = items().filter((li) => li.querySelector('.topic-list-item--archived'))
|
|
58
|
+
expect(archived).toHaveLength(1)
|
|
59
|
+
expect(archived[0].textContent).toContain('#Zeta')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('filters by label substring and adds NO create option', () => {
|
|
63
|
+
controller.openForTopics(DATA, RECT, () => {})
|
|
64
|
+
controller.inputTarget.value = 'alpha'
|
|
65
|
+
controller._onInput()
|
|
66
|
+
const labels = items().map((li) => li.textContent.trim())
|
|
67
|
+
expect(labels).toEqual(['#Alpha'])
|
|
68
|
+
|
|
69
|
+
controller.inputTarget.value = 'brandnew'
|
|
70
|
+
controller._onInput()
|
|
71
|
+
expect(items()).toHaveLength(0) // no "create and move" pseudo-item
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('select invokes the callback with the item and closes', () => {
|
|
75
|
+
const cb = jest.fn()
|
|
76
|
+
controller.openForTopics(DATA, RECT, cb)
|
|
77
|
+
const item = { id: 2, label: '#Alpha', archived: false }
|
|
78
|
+
controller.select(item)
|
|
79
|
+
expect(cb).toHaveBeenCalledWith(item)
|
|
80
|
+
expect(controller.popup.isOpen()).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { Application } from '@hotwired/stimulus'
|
|
6
|
+
import TopicsController from '../topics_controller'
|
|
7
|
+
|
|
8
|
+
describe('TopicsController#openTopicListPopup', () => {
|
|
9
|
+
let application, controller
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
global.requestAnimationFrame = (fn) => { fn(); return 0 }
|
|
13
|
+
document.body.innerHTML = `
|
|
14
|
+
<div id="comments-popup" data-controller="comments--topics"
|
|
15
|
+
data-topic-main-text="All Messages"
|
|
16
|
+
data-topic-search-placeholder-text="Search topics...">
|
|
17
|
+
<div data-comments--topics-target="list"></div>
|
|
18
|
+
</div>
|
|
19
|
+
`
|
|
20
|
+
application = Application.start()
|
|
21
|
+
application.register('comments--topics', TopicsController)
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
23
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
24
|
+
document.getElementById('comments-popup'), 'comments--topics'
|
|
25
|
+
)
|
|
26
|
+
controller.topics = [{ id: 2, name: 'Alpha' }]
|
|
27
|
+
controller.archivedTopics = [{ id: 3, name: 'Zeta' }]
|
|
28
|
+
controller.mainTopicId = null
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
document.body.innerHTML = ''
|
|
34
|
+
application.stop()
|
|
35
|
+
jest.clearAllMocks()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('creates the topic-list modal with the required targets', () => {
|
|
39
|
+
const btn = document.createElement('button')
|
|
40
|
+
document.body.appendChild(btn)
|
|
41
|
+
controller.openTopicListPopup({ currentTarget: btn })
|
|
42
|
+
|
|
43
|
+
const modal = document.getElementById('topic-list-modal')
|
|
44
|
+
expect(modal).not.toBeNull()
|
|
45
|
+
expect(modal.dataset.controller).toBe('topic-list')
|
|
46
|
+
expect(modal.querySelector('[data-topic-list-target="input"]')).not.toBeNull()
|
|
47
|
+
expect(modal.querySelector('[data-topic-list-target="list"]')).not.toBeNull()
|
|
48
|
+
expect(modal.querySelector('[data-topic-list-target="close"]')).not.toBeNull()
|
|
49
|
+
expect(modal.querySelector('input').placeholder).toBe('Search topics...')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('appends the modal inside the chat box so it is caged within it', () => {
|
|
53
|
+
const btn = document.createElement('button')
|
|
54
|
+
document.body.appendChild(btn)
|
|
55
|
+
controller.openTopicListPopup({ currentTarget: btn })
|
|
56
|
+
|
|
57
|
+
const modal = document.getElementById('topic-list-modal')
|
|
58
|
+
// Bounded to the chat popup, not body-level.
|
|
59
|
+
expect(modal.parentElement).toBe(document.getElementById('comments-popup'))
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -468,6 +468,53 @@ export default class extends Controller {
|
|
|
468
468
|
this.restoreSelection()
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
+
openTopicListPopup(event) {
|
|
472
|
+
const btnRect = event.currentTarget.getBoundingClientRect()
|
|
473
|
+
|
|
474
|
+
const openWith = (popup) => {
|
|
475
|
+
popup.openForTopics(
|
|
476
|
+
{
|
|
477
|
+
topics: this.topics || [],
|
|
478
|
+
archivedTopics: this.archivedTopics || [],
|
|
479
|
+
mainTopicId: this.mainTopicId,
|
|
480
|
+
allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
|
|
481
|
+
},
|
|
482
|
+
btnRect,
|
|
483
|
+
(item) => this.selectTopic(item.id),
|
|
484
|
+
this.element
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let modal = document.getElementById('topic-list-modal')
|
|
489
|
+
if (modal) {
|
|
490
|
+
const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
491
|
+
if (popup) openWith(popup)
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
modal = document.createElement('div')
|
|
496
|
+
modal.id = 'topic-list-modal'
|
|
497
|
+
modal.className = 'common-popup'
|
|
498
|
+
modal.style.display = 'none'
|
|
499
|
+
modal.dataset.controller = 'topic-list'
|
|
500
|
+
modal.innerHTML = `
|
|
501
|
+
<button type="button" class="popup-close-btn" data-topic-list-target="close">×</button>
|
|
502
|
+
<input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
|
|
503
|
+
placeholder="${this.element.dataset.topicSearchPlaceholderText || 'Search topics...'}"
|
|
504
|
+
data-topic-list-target="input">
|
|
505
|
+
<ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
|
|
506
|
+
`
|
|
507
|
+
// Append into the chat box (this.element === #comments-popup) so the popup
|
|
508
|
+
// is caged within it and shares its stacking context.
|
|
509
|
+
this.element.appendChild(modal)
|
|
510
|
+
|
|
511
|
+
requestAnimationFrame(() => {
|
|
512
|
+
const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
513
|
+
if (popup) openWith(popup)
|
|
514
|
+
else console.error('topic-list controller not found after creation')
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
471
518
|
showInput(event) {
|
|
472
519
|
event.preventDefault()
|
|
473
520
|
const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
|
|
@@ -22,6 +22,7 @@ import ClickTargetController from "./click_target_controller"
|
|
|
22
22
|
import TabsController from "./tabs_controller"
|
|
23
23
|
import LinkCreativeController from "./link_creative_controller"
|
|
24
24
|
import TopicSearchController from "./topic_search_controller"
|
|
25
|
+
import TopicListController from "./topic_list_controller"
|
|
25
26
|
import CommonPopupController from "./common_popup_controller"
|
|
26
27
|
import CommentController from "./comment_controller"
|
|
27
28
|
import ReactionPickerController from "./reaction_picker_controller"
|
|
@@ -59,6 +60,7 @@ export {
|
|
|
59
60
|
TabsController,
|
|
60
61
|
LinkCreativeController,
|
|
61
62
|
TopicSearchController,
|
|
63
|
+
TopicListController,
|
|
62
64
|
CommonPopupController,
|
|
63
65
|
CommentController,
|
|
64
66
|
ReactionPickerController,
|
|
@@ -98,6 +100,7 @@ export function registerControllers(application) {
|
|
|
98
100
|
application.register("tabs", TabsController)
|
|
99
101
|
application.register("link-creative", LinkCreativeController)
|
|
100
102
|
application.register("topic-search", TopicSearchController)
|
|
103
|
+
application.register("topic-list", TopicListController)
|
|
101
104
|
application.register("common-popup", CommonPopupController)
|
|
102
105
|
application.register("comment", CommentController)
|
|
103
106
|
application.register("reaction-picker", ReactionPickerController)
|
|
@@ -9,9 +9,14 @@ export default class extends Controller {
|
|
|
9
9
|
}
|
|
10
10
|
document.addEventListener('keydown', this._escHandler)
|
|
11
11
|
|
|
12
|
-
// Global keyboard shortcut: Cmd/Ctrl + K to open search
|
|
12
|
+
// Global keyboard shortcut: Cmd/Ctrl + K to open search.
|
|
13
|
+
// Skip when focus is inside an editable element (input, textarea,
|
|
14
|
+
// contenteditable, or the inline Lexical editor) so that element's own
|
|
15
|
+
// Ctrl+K behavior (e.g. delete-to-end-of-line) takes precedence over the
|
|
16
|
+
// global search popup.
|
|
13
17
|
this._shortcutHandler = (e) => {
|
|
14
18
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
19
|
+
if (this._isEditableTarget(e.target)) return
|
|
15
20
|
e.preventDefault()
|
|
16
21
|
this.toggle()
|
|
17
22
|
}
|
|
@@ -19,6 +24,17 @@ export default class extends Controller {
|
|
|
19
24
|
document.addEventListener('keydown', this._shortcutHandler)
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
_isEditableTarget(target) {
|
|
28
|
+
const el = target instanceof Element ? target : document.activeElement
|
|
29
|
+
if (!el || typeof el.closest !== 'function') return false
|
|
30
|
+
const tag = el.tagName
|
|
31
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
32
|
+
if (el.isContentEditable) return true
|
|
33
|
+
return Boolean(
|
|
34
|
+
el.closest('[contenteditable]:not([contenteditable="false"]), [data-lexical-editor-root]')
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
disconnect() {
|
|
23
39
|
document.removeEventListener('keydown', this._escHandler)
|
|
24
40
|
document.removeEventListener('keydown', this._shortcutHandler)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import CommonPopupController from './common_popup_controller'
|
|
2
|
+
|
|
3
|
+
const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
|
|
4
|
+
|
|
5
|
+
export default class extends CommonPopupController {
|
|
6
|
+
static targets = ['input', 'list', 'close']
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
super.connect()
|
|
10
|
+
this._allItems = []
|
|
11
|
+
this._onInputBound = this._onInput.bind(this)
|
|
12
|
+
this._onKeydownBound = this.handleInputKeydown.bind(this)
|
|
13
|
+
this._onCloseBound = () => this.close()
|
|
14
|
+
this.inputTarget.addEventListener('input', this._onInputBound)
|
|
15
|
+
this.inputTarget.addEventListener('keydown', this._onKeydownBound)
|
|
16
|
+
this.closeTarget.addEventListener('click', this._onCloseBound)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
this.inputTarget?.removeEventListener('input', this._onInputBound)
|
|
21
|
+
this.inputTarget?.removeEventListener('keydown', this._onKeydownBound)
|
|
22
|
+
this.closeTarget?.removeEventListener('click', this._onCloseBound)
|
|
23
|
+
super.disconnect()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
openForTopics({ topics = [], archivedTopics = [], mainTopicId = null, allMessagesLabel = 'All Messages' }, anchorRect, onSelectCallback, boundsElement = null) {
|
|
27
|
+
this.onSelectCallback = onSelectCallback
|
|
28
|
+
this._allItems = this._buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel })
|
|
29
|
+
this.inputTarget.value = ''
|
|
30
|
+
this.setItems(this._allItems)
|
|
31
|
+
super.open(anchorRect, boundsElement)
|
|
32
|
+
requestAnimationFrame(() => this.inputTarget.focus())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel }) {
|
|
36
|
+
const main = mainTopicId ? topics.find(t => String(t.id) === String(mainTopicId)) : null
|
|
37
|
+
const others = topics.filter(t => !main || String(t.id) !== String(mainTopicId))
|
|
38
|
+
const items = []
|
|
39
|
+
if (main) items.push({ id: main.id, label: `#${main.name}`, archived: false })
|
|
40
|
+
others.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: false }))
|
|
41
|
+
items.push({ id: '', label: `📋 ${allMessagesLabel}`, archived: false })
|
|
42
|
+
archivedTopics.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: true }))
|
|
43
|
+
return items
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_onInput() {
|
|
47
|
+
const q = this.inputTarget.value.toLowerCase().trim()
|
|
48
|
+
if (!q) { this.setItems(this._allItems); return }
|
|
49
|
+
const filtered = this._allItems.filter(i => (i.label || '').toLowerCase().includes(q))
|
|
50
|
+
this.setItems(filtered)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handleInputKeydown(event) {
|
|
54
|
+
if (this.handleKey(event)) return
|
|
55
|
+
if (event.key === 'Escape') this.close()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
select(item) {
|
|
59
|
+
if (this.onSelectCallback) this.onSelectCallback(item)
|
|
60
|
+
this.close()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
renderItem(item) {
|
|
64
|
+
const escaped = String(item.label || '')
|
|
65
|
+
.replace(/&/g, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
if (item.archived) {
|
|
69
|
+
return `<span class="topic-list-item topic-list-item--archived">${ICON_ARCHIVE} ${escaped}</span>`
|
|
70
|
+
}
|
|
71
|
+
return `<span class="topic-list-item">${escaped}</span>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dispatchClose(reason) {
|
|
75
|
+
this.onSelectCallback = null
|
|
76
|
+
super.dispatchClose(reason)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import CommonPopup from '../common_popup'
|
|
5
|
+
|
|
6
|
+
// Cage-inside-bounds positioning: when a boundsElement is passed to showAt,
|
|
7
|
+
// updatePosition must clamp the popup within that element's rect (not the
|
|
8
|
+
// viewport) and cap its size so a long list scrolls inside instead of spilling.
|
|
9
|
+
describe('CommonPopup bounds clamping', () => {
|
|
10
|
+
const BOUNDS = { left: 100, top: 100, right: 500, bottom: 700, width: 400, height: 600 }
|
|
11
|
+
|
|
12
|
+
let element, boundsElement
|
|
13
|
+
|
|
14
|
+
const build = ({ popupWidth, popupHeight }) => {
|
|
15
|
+
boundsElement = document.createElement('div')
|
|
16
|
+
boundsElement.getBoundingClientRect = () => BOUNDS
|
|
17
|
+
|
|
18
|
+
element = document.createElement('div')
|
|
19
|
+
// offsetParent is the bounds (chat box); parentRect === BOUNDS.
|
|
20
|
+
Object.defineProperty(element, 'offsetParent', { value: boundsElement, configurable: true })
|
|
21
|
+
Object.defineProperty(element, 'offsetWidth', { value: popupWidth, configurable: true })
|
|
22
|
+
Object.defineProperty(element, 'offsetHeight', { value: popupHeight, configurable: true })
|
|
23
|
+
document.body.appendChild(element)
|
|
24
|
+
return new CommonPopup(element, { listElement: document.createElement('ul') })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
28
|
+
|
|
29
|
+
test('clamps an anchor near the right/bottom edge back inside the bounds', () => {
|
|
30
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
31
|
+
popup._boundsElement = boundsElement
|
|
32
|
+
// Anchor button hard against the right edge — would overflow the box.
|
|
33
|
+
// maxLeft = right - width - pad = 500 - 320 - 8 = 172; parent coords: 172 - 100 = 72
|
|
34
|
+
popup.updatePosition({ left: 450, bottom: 130 })
|
|
35
|
+
expect(element.style.left).toBe('72px')
|
|
36
|
+
// viewportTop = 130 + 4 = 134; within [108, 292] → 134; parent coords: 134 - 100 = 34
|
|
37
|
+
expect(element.style.top).toBe('34px')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('caps max size to fit inside the bounds', () => {
|
|
41
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
42
|
+
popup._boundsElement = boundsElement
|
|
43
|
+
popup.updatePosition({ left: 200, bottom: 200 })
|
|
44
|
+
// inner box = dimension - 2*pad
|
|
45
|
+
expect(element.style.maxWidth).toBe('384px')
|
|
46
|
+
expect(element.style.maxHeight).toBe('584px')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('falls back to viewport clamping when no bounds element is set', () => {
|
|
50
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
51
|
+
popup._boundsElement = null
|
|
52
|
+
// jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
|
|
53
|
+
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
54
|
+
popup.updatePosition({ left: 0, bottom: 0 })
|
|
55
|
+
expect(element.style.maxHeight).toBe('')
|
|
56
|
+
expect(element.style.left).toBe('8px')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -12,9 +12,13 @@ export default class CommonPopup {
|
|
|
12
12
|
this.handleOutsideClick = this.handleOutsideClick.bind(this)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
showAt(anchorRect) {
|
|
15
|
+
showAt(anchorRect, boundsElement = null) {
|
|
16
16
|
if (!this.element) return
|
|
17
17
|
|
|
18
|
+
// When set, the popup is caged inside this element's rect (e.g. the chat
|
|
19
|
+
// box) instead of the viewport — see updatePosition.
|
|
20
|
+
this._boundsElement = boundsElement
|
|
21
|
+
|
|
18
22
|
// Re-opening while already open (e.g. clicking the same typo mark twice):
|
|
19
23
|
// a listener from the previous open is still live, so the opening mousedown
|
|
20
24
|
// would bubble to it and hide() the popup right after we set display:block,
|
|
@@ -58,11 +62,26 @@ export default class CommonPopup {
|
|
|
58
62
|
let viewportTop = (rect?.bottom || 0) + 4
|
|
59
63
|
|
|
60
64
|
const { offsetWidth: width, offsetHeight: height } = this.element
|
|
61
|
-
const maxLeft = window.innerWidth - width - boundsPadding
|
|
62
|
-
const maxTop = window.innerHeight - height - boundsPadding
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
// Clamp within a container's rect when bounded (keeps the popup caged inside
|
|
67
|
+
// the chat box), otherwise within the viewport.
|
|
68
|
+
const bounds = this._boundsElement?.getBoundingClientRect?.()
|
|
69
|
+
let minLeft = boundsPadding
|
|
70
|
+
let minTop = boundsPadding
|
|
71
|
+
let maxLeft = window.innerWidth - width - boundsPadding
|
|
72
|
+
let maxTop = window.innerHeight - height - boundsPadding
|
|
73
|
+
if (bounds) {
|
|
74
|
+
minLeft = bounds.left + boundsPadding
|
|
75
|
+
minTop = bounds.top + boundsPadding
|
|
76
|
+
maxLeft = bounds.right - width - boundsPadding
|
|
77
|
+
maxTop = bounds.bottom - height - boundsPadding
|
|
78
|
+
// Cap size so a long list scrolls inside the box instead of spilling past it.
|
|
79
|
+
this.element.style.maxWidth = `${bounds.width - boundsPadding * 2}px`
|
|
80
|
+
this.element.style.maxHeight = `${bounds.height - boundsPadding * 2}px`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
viewportLeft = Math.max(minLeft, Math.min(viewportLeft, maxLeft))
|
|
84
|
+
viewportTop = Math.max(minTop, Math.min(viewportTop, maxTop))
|
|
66
85
|
|
|
67
86
|
const left = viewportLeft - parentRect.left + parentScrollX
|
|
68
87
|
const top = viewportTop - parentRect.top + parentScrollY
|