coplan-engine 0.2.0 → 1.0.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/coplan/application.css +350 -7
- data/app/channels/coplan/plan_presence_channel.rb +45 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/operations_controller.rb +41 -3
- data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
- data/app/controllers/coplan/plans_controller.rb +1 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
- data/app/helpers/coplan/application_helper.rb +57 -0
- data/app/helpers/coplan/comments_helper.rb +4 -3
- data/app/helpers/coplan/markdown_helper.rb +7 -1
- data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
- data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
- data/app/javascript/controllers/coplan/presence_controller.js +44 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +203 -56
- data/app/models/coplan/comment.rb +4 -0
- data/app/models/coplan/comment_thread.rb +58 -16
- data/app/models/coplan/plan.rb +1 -0
- data/app/models/coplan/plan_viewer.rb +26 -0
- data/app/services/coplan/plans/apply_operations.rb +43 -0
- data/app/services/coplan/plans/commit_session.rb +26 -1
- data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
- data/app/services/coplan/plans/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
- data/app/views/coplan/comments/_comment.html.erb +3 -0
- data/app/views/coplan/plans/_header.html.erb +1 -0
- data/app/views/coplan/plans/_viewers.html.erb +16 -0
- data/app/views/coplan/plans/show.html.erb +25 -3
- data/app/views/layouts/coplan/application.html.erb +2 -0
- data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
- data/lib/coplan/version.rb +1 -1
- metadata +8 -1
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
module CoPlan
|
|
2
2
|
module CommentsHelper
|
|
3
3
|
def comment_author_name(comment)
|
|
4
|
-
case comment.author_type
|
|
4
|
+
user_name = case comment.author_type
|
|
5
5
|
when "human"
|
|
6
6
|
CoPlan::User.find_by(id: comment.author_id)&.name || "Unknown"
|
|
7
7
|
when "local_agent"
|
|
8
|
-
|
|
8
|
+
CoPlan::User
|
|
9
9
|
.joins(:api_tokens)
|
|
10
10
|
.where(coplan_api_tokens: { id: comment.author_id })
|
|
11
11
|
.pick(:name) || "Agent"
|
|
12
|
-
comment.agent_name.present? ? "#{user_name} (#{comment.agent_name})" : user_name
|
|
13
12
|
when "cloud_persona"
|
|
14
13
|
AutomatedPlanReviewer.find_by(id: comment.author_id)&.name || "Reviewer"
|
|
15
14
|
else
|
|
16
15
|
comment.author_type
|
|
17
16
|
end
|
|
17
|
+
|
|
18
|
+
comment.agent_name.present? ? "#{comment.agent_name} (via #{user_name})" : user_name
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -11,16 +11,22 @@ module CoPlan
|
|
|
11
11
|
blockquote hr br
|
|
12
12
|
dd dt dl
|
|
13
13
|
sup sub
|
|
14
|
+
details summary
|
|
14
15
|
].freeze
|
|
15
16
|
|
|
16
17
|
ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
|
|
17
18
|
|
|
18
19
|
def render_markdown(content)
|
|
19
|
-
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
|
|
20
|
+
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil })
|
|
20
21
|
sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
|
|
21
22
|
tag.div(sanitized, class: "markdown-rendered")
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
def markdown_to_plain_text(content)
|
|
26
|
+
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
|
|
27
|
+
Nokogiri::HTML::DocumentFragment.parse(html).text.squish
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def render_line_view(content)
|
|
25
31
|
lines = content.to_s.split("\n", -1)
|
|
26
32
|
line_divs = lines.each_with_index.map do |line, index|
|
|
@@ -6,13 +6,18 @@ export default class extends Controller {
|
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
this.currentIndex = -1
|
|
9
|
+
this.activeMark = null
|
|
10
|
+
this.activePopover = null
|
|
9
11
|
this.updatePosition()
|
|
10
12
|
this.handleKeydown = this.handleKeydown.bind(this)
|
|
13
|
+
this.handleScroll = this.handleScroll.bind(this)
|
|
11
14
|
document.addEventListener("keydown", this.handleKeydown)
|
|
15
|
+
window.addEventListener("scroll", this.handleScroll, { passive: true })
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
disconnect() {
|
|
15
19
|
document.removeEventListener("keydown", this.handleKeydown)
|
|
20
|
+
window.removeEventListener("scroll", this.handleScroll)
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
handleKeydown(event) {
|
|
@@ -36,6 +41,14 @@ export default class extends Controller {
|
|
|
36
41
|
event.preventDefault()
|
|
37
42
|
this.focusReply()
|
|
38
43
|
break
|
|
44
|
+
case "a":
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
this.acceptCurrent()
|
|
47
|
+
break
|
|
48
|
+
case "d":
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
this.discardCurrent()
|
|
51
|
+
break
|
|
39
52
|
}
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -69,42 +82,142 @@ export default class extends Controller {
|
|
|
69
82
|
el.classList.remove("anchor-highlight--active")
|
|
70
83
|
})
|
|
71
84
|
|
|
85
|
+
// Close any currently open popover first — showPopover() throws
|
|
86
|
+
// InvalidStateError if the same popover is already open, and
|
|
87
|
+
// auto-dismiss only works when showing a *different* popover.
|
|
88
|
+
const openPopover = this.findOpenPopover()
|
|
89
|
+
if (openPopover) {
|
|
90
|
+
try { openPopover.hidePopover() } catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
// Add active class and scroll into view
|
|
73
94
|
mark.classList.add("anchor-highlight--active")
|
|
74
|
-
mark.scrollIntoView({ behavior: "
|
|
95
|
+
mark.scrollIntoView({ behavior: "instant", block: "center" })
|
|
75
96
|
|
|
76
97
|
// Open the thread popover if there's one
|
|
77
98
|
const threadId = mark.dataset.threadId
|
|
78
99
|
if (threadId) {
|
|
79
100
|
const popover = document.getElementById(`${threadId}_popover`)
|
|
80
101
|
if (popover) {
|
|
81
|
-
|
|
102
|
+
popover.style.visibility = "hidden"
|
|
82
103
|
popover.showPopover()
|
|
83
|
-
popover
|
|
84
|
-
popover.style.
|
|
104
|
+
this.positionPopoverAtMark(popover, mark)
|
|
105
|
+
popover.style.visibility = "visible"
|
|
106
|
+
this.activeMark = mark
|
|
107
|
+
this.activePopover = popover
|
|
85
108
|
}
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
this.updatePosition()
|
|
89
112
|
}
|
|
90
113
|
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
handleScroll() {
|
|
115
|
+
if (!this.activeMark || !this.activePopover) return
|
|
93
116
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
if (!this.activePopover.matches(":popover-open")) {
|
|
118
|
+
this.activeMark = null
|
|
119
|
+
this.activePopover = null
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
} catch { return }
|
|
123
|
+
this.positionPopoverAtMark(this.activePopover, this.activeMark)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
positionPopoverAtMark(popover, mark) {
|
|
127
|
+
const markRect = mark.getBoundingClientRect()
|
|
128
|
+
const popoverRect = popover.getBoundingClientRect()
|
|
129
|
+
const viewportWidth = window.innerWidth
|
|
130
|
+
const viewportHeight = window.innerHeight
|
|
131
|
+
|
|
132
|
+
let top = markRect.top
|
|
133
|
+
let left = markRect.right + 12
|
|
134
|
+
|
|
135
|
+
if (left + popoverRect.width > viewportWidth - 16) {
|
|
136
|
+
left = markRect.left - popoverRect.width - 12
|
|
137
|
+
}
|
|
138
|
+
if (top + popoverRect.height > viewportHeight - 16) {
|
|
139
|
+
top = viewportHeight - popoverRect.height - 16
|
|
99
140
|
}
|
|
100
|
-
if (
|
|
141
|
+
if (top < 16) top = 16
|
|
142
|
+
if (left < 16) left = 16
|
|
101
143
|
|
|
102
|
-
|
|
144
|
+
popover.style.top = `${top}px`
|
|
145
|
+
popover.style.left = `${left}px`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
focusReply() {
|
|
149
|
+
const popover = this.findOpenPopover()
|
|
150
|
+
if (!popover) return
|
|
151
|
+
|
|
152
|
+
const textarea = popover.querySelector(".thread-popover__reply textarea")
|
|
103
153
|
if (textarea) {
|
|
104
154
|
textarea.focus({ preventScroll: true })
|
|
105
155
|
}
|
|
106
156
|
}
|
|
107
157
|
|
|
158
|
+
acceptCurrent() {
|
|
159
|
+
this.submitPopoverAction("accept")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
discardCurrent() {
|
|
163
|
+
this.submitPopoverAction("discard")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
submitPopoverAction(action) {
|
|
167
|
+
const popover = this.findOpenPopover()
|
|
168
|
+
if (!popover) return
|
|
169
|
+
|
|
170
|
+
const form = popover.querySelector(`form[data-action-name='${action}']`)
|
|
171
|
+
if (!form) return
|
|
172
|
+
|
|
173
|
+
// Normalize currentIndex if popover was opened via mouse (not j/k)
|
|
174
|
+
if (this.currentIndex < 0) {
|
|
175
|
+
this.currentIndex = 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// For accept (pending→todo), the thread stays open so we need to
|
|
179
|
+
// explicitly advance. For discard, the thread leaves openHighlights
|
|
180
|
+
// and the current index naturally points to the next one.
|
|
181
|
+
const shouldAdvance = action === "accept"
|
|
182
|
+
|
|
183
|
+
// Watch for the broadcast DOM update that replaces the thread data,
|
|
184
|
+
// then advance to the next thread once the highlights have changed.
|
|
185
|
+
const threadsContainer = document.getElementById("plan-threads")
|
|
186
|
+
if (threadsContainer) {
|
|
187
|
+
const observer = new MutationObserver(() => {
|
|
188
|
+
observer.disconnect()
|
|
189
|
+
this.advanceAfterAction(shouldAdvance)
|
|
190
|
+
})
|
|
191
|
+
observer.observe(threadsContainer, { childList: true, subtree: true })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
form.requestSubmit()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
advanceAfterAction(shouldAdvance) {
|
|
198
|
+
const highlights = this.openHighlights
|
|
199
|
+
if (highlights.length === 0) {
|
|
200
|
+
this.currentIndex = -1
|
|
201
|
+
this.updatePosition()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (shouldAdvance) {
|
|
205
|
+
this.currentIndex = (this.currentIndex + 1) % highlights.length
|
|
206
|
+
} else if (this.currentIndex >= highlights.length) {
|
|
207
|
+
this.currentIndex = 0
|
|
208
|
+
}
|
|
209
|
+
this.navigateTo(highlights[this.currentIndex])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
findOpenPopover() {
|
|
213
|
+
try {
|
|
214
|
+
return document.querySelector(".thread-popover:popover-open")
|
|
215
|
+
} catch {
|
|
216
|
+
return Array.from(document.querySelectorAll(".thread-popover[popover]"))
|
|
217
|
+
.find(el => el.checkVisibility?.())
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
108
221
|
toggleResolved() {
|
|
109
222
|
const planLayout = document.querySelector(".plan-layout")
|
|
110
223
|
if (!planLayout) return
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "coplan:content-nav-visible"
|
|
4
|
+
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["sidebar", "list", "content", "toggleBtn", "showBtn"]
|
|
7
|
+
static values = { visible: { type: Boolean, default: true } }
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
11
|
+
if (stored !== null) {
|
|
12
|
+
this.visibleValue = stored === "true"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
this.buildToc()
|
|
16
|
+
this.setupScrollTracking()
|
|
17
|
+
|
|
18
|
+
this.handleKeydown = this.handleKeydown.bind(this)
|
|
19
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
20
|
+
|
|
21
|
+
this._handleAnchorsUpdated = () => this.updateCommentBadges()
|
|
22
|
+
this.element.addEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
if (this._scrollHandler) {
|
|
27
|
+
window.removeEventListener("scroll", this._scrollHandler)
|
|
28
|
+
}
|
|
29
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
30
|
+
if (this._handleAnchorsUpdated) {
|
|
31
|
+
this.element.removeEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
buildToc() {
|
|
36
|
+
const rendered = this.contentTarget.querySelector(".markdown-rendered")
|
|
37
|
+
if (!rendered) return
|
|
38
|
+
|
|
39
|
+
this.listTarget.innerHTML = ""
|
|
40
|
+
this._itemsById = new Map()
|
|
41
|
+
this._headings = Array.from(rendered.querySelectorAll("h1, h2, h3"))
|
|
42
|
+
|
|
43
|
+
if (this._headings.length === 0) {
|
|
44
|
+
this.sidebarTarget.style.display = "none"
|
|
45
|
+
if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "none"
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
this.sidebarTarget.style.display = ""
|
|
49
|
+
if (this.hasShowBtnTarget) this.showBtnTarget.style.display = ""
|
|
50
|
+
|
|
51
|
+
const usedIds = new Set()
|
|
52
|
+
|
|
53
|
+
this._headings.forEach((heading, index) => {
|
|
54
|
+
let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}`
|
|
55
|
+
let id = baseId
|
|
56
|
+
let suffix = 2
|
|
57
|
+
while (usedIds.has(id)) {
|
|
58
|
+
id = `${baseId}-${suffix++}`
|
|
59
|
+
}
|
|
60
|
+
heading.id = id
|
|
61
|
+
usedIds.add(id)
|
|
62
|
+
|
|
63
|
+
const li = document.createElement("li")
|
|
64
|
+
li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}`
|
|
65
|
+
li.dataset.headingId = id
|
|
66
|
+
|
|
67
|
+
const a = document.createElement("a")
|
|
68
|
+
a.className = "content-nav__link"
|
|
69
|
+
a.href = `#${id}`
|
|
70
|
+
a.addEventListener("click", (e) => this.handleLinkClick(e, heading))
|
|
71
|
+
|
|
72
|
+
const text = document.createElement("span")
|
|
73
|
+
text.className = "content-nav__link-text"
|
|
74
|
+
text.textContent = heading.textContent
|
|
75
|
+
a.appendChild(text)
|
|
76
|
+
|
|
77
|
+
li.appendChild(a)
|
|
78
|
+
this.listTarget.appendChild(li)
|
|
79
|
+
this._itemsById.set(id, li)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
this.updateCommentBadges()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
slugify(text) {
|
|
86
|
+
return text
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/\s+/g, "-")
|
|
89
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
90
|
+
.replace(/-{2,}/g, "-")
|
|
91
|
+
.replace(/^-|-$/g, "")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setupScrollTracking() {
|
|
95
|
+
if (!this._headings || this._headings.length === 0) return
|
|
96
|
+
|
|
97
|
+
this._scrollHandler = () => {
|
|
98
|
+
if (this._ignoreScroll) {
|
|
99
|
+
clearTimeout(this._scrollEndTimer)
|
|
100
|
+
this._scrollEndTimer = setTimeout(() => {
|
|
101
|
+
this._ignoreScroll = false
|
|
102
|
+
}, 100)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!this._scrollTicking) {
|
|
106
|
+
requestAnimationFrame(() => {
|
|
107
|
+
this._updateActiveFromScroll()
|
|
108
|
+
this._scrollTicking = false
|
|
109
|
+
})
|
|
110
|
+
this._scrollTicking = true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
window.addEventListener("scroll", this._scrollHandler, { passive: true })
|
|
114
|
+
this._updateActiveFromScroll()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_updateActiveFromScroll() {
|
|
118
|
+
const threshold = 100
|
|
119
|
+
let active = null
|
|
120
|
+
for (const heading of this._headings) {
|
|
121
|
+
if (heading.getBoundingClientRect().top <= threshold) {
|
|
122
|
+
active = heading
|
|
123
|
+
} else {
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const id = active?.id || this._headings[0]?.id
|
|
129
|
+
if (id && id !== this._activeHeadingId) {
|
|
130
|
+
this._activeHeadingId = id
|
|
131
|
+
this._setActiveLink(id)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
handleLinkClick(event, heading) {
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
history.replaceState(null, "", `#${heading.id}`)
|
|
138
|
+
|
|
139
|
+
this._ignoreScroll = true
|
|
140
|
+
this._activeHeadingId = heading.id
|
|
141
|
+
this._setActiveLink(heading.id)
|
|
142
|
+
|
|
143
|
+
heading.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_setActiveLink(id) {
|
|
147
|
+
this.listTarget.querySelectorAll(".content-nav__link").forEach(link => {
|
|
148
|
+
link.classList.remove("content-nav__link--active")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const item = this._itemsById?.get(id)
|
|
152
|
+
const link = item?.querySelector(".content-nav__link")
|
|
153
|
+
if (link) {
|
|
154
|
+
link.classList.add("content-nav__link--active")
|
|
155
|
+
link.scrollIntoView({ block: "nearest" })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
toggle() {
|
|
160
|
+
this.visibleValue = !this.visibleValue
|
|
161
|
+
localStorage.setItem(STORAGE_KEY, this.visibleValue)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
visibleValueChanged() {
|
|
165
|
+
if (!this.hasSidebarTarget) return
|
|
166
|
+
|
|
167
|
+
const hidden = !this.visibleValue
|
|
168
|
+
this.sidebarTarget.classList.toggle("content-nav--hidden", hidden)
|
|
169
|
+
this.sidebarTarget.setAttribute("aria-hidden", String(hidden))
|
|
170
|
+
if (hidden) {
|
|
171
|
+
this.sidebarTarget.setAttribute("inert", "")
|
|
172
|
+
} else {
|
|
173
|
+
this.sidebarTarget.removeAttribute("inert")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.hasToggleBtnTarget) {
|
|
177
|
+
this.toggleBtnTarget.setAttribute("aria-expanded", String(this.visibleValue))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
handleKeydown(event) {
|
|
182
|
+
const tag = event.target.tagName
|
|
183
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
|
|
184
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
185
|
+
|
|
186
|
+
if (event.key === "]") {
|
|
187
|
+
event.preventDefault()
|
|
188
|
+
this.toggle()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
updateCommentBadges() {
|
|
193
|
+
if (!this._headings || this._headings.length === 0) return
|
|
194
|
+
|
|
195
|
+
const rendered = this.contentTarget.querySelector(".markdown-rendered")
|
|
196
|
+
if (!rendered) return
|
|
197
|
+
|
|
198
|
+
this._headings.forEach((heading, index) => {
|
|
199
|
+
const nextHeading = this._headings[index + 1]
|
|
200
|
+
const threads = this.collectThreadsBetween(heading, nextHeading, rendered)
|
|
201
|
+
|
|
202
|
+
let pendingCount = 0
|
|
203
|
+
let todoCount = 0
|
|
204
|
+
threads.forEach(status => {
|
|
205
|
+
if (status === "pending") pendingCount++
|
|
206
|
+
else if (status === "todo") todoCount++
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const item = this._itemsById?.get(heading.id)
|
|
210
|
+
if (!item) return
|
|
211
|
+
|
|
212
|
+
const existing = item.querySelector(".content-nav__badge")
|
|
213
|
+
if (existing) existing.remove()
|
|
214
|
+
|
|
215
|
+
const total = pendingCount + todoCount
|
|
216
|
+
if (total === 0) return
|
|
217
|
+
|
|
218
|
+
const badge = document.createElement("span")
|
|
219
|
+
const badgeType = pendingCount > 0 ? "pending" : "todo"
|
|
220
|
+
badge.className = `content-nav__badge content-nav__badge--${badgeType}`
|
|
221
|
+
badge.textContent = total
|
|
222
|
+
item.querySelector(".content-nav__link").appendChild(badge)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
collectThreadsBetween(startHeading, endHeading, container) {
|
|
227
|
+
const seen = new Set()
|
|
228
|
+
const threads = []
|
|
229
|
+
let collecting = false
|
|
230
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, null)
|
|
231
|
+
|
|
232
|
+
let node
|
|
233
|
+
while ((node = walker.nextNode())) {
|
|
234
|
+
if (node === startHeading) {
|
|
235
|
+
collecting = true
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (endHeading && node === endHeading) break
|
|
239
|
+
|
|
240
|
+
if (collecting && node.tagName === "MARK" && node.classList.contains("anchor-highlight--open")) {
|
|
241
|
+
const threadId = node.dataset.threadId
|
|
242
|
+
if (threadId && !seen.has(threadId)) {
|
|
243
|
+
seen.add(threadId)
|
|
244
|
+
const status = node.classList.contains("anchor-highlight--pending") ? "pending" : "todo"
|
|
245
|
+
threads.push(status)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return threads
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { createConsumer } from "@rails/actioncable"
|
|
3
|
+
|
|
4
|
+
let sharedConsumer
|
|
5
|
+
|
|
6
|
+
function getConsumer() {
|
|
7
|
+
if (!sharedConsumer) sharedConsumer = createConsumer()
|
|
8
|
+
return sharedConsumer
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { planId: String }
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this.channel = getConsumer().subscriptions.create(
|
|
16
|
+
{ channel: "CoPlan::PlanPresenceChannel", plan_id: this.planIdValue },
|
|
17
|
+
{
|
|
18
|
+
connected: () => { this.startPinging() },
|
|
19
|
+
disconnected: () => { this.stopPinging() }
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
this.stopPinging()
|
|
26
|
+
if (this.channel) {
|
|
27
|
+
this.channel.unsubscribe()
|
|
28
|
+
this.channel = null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
startPinging() {
|
|
33
|
+
this.pingInterval = setInterval(() => {
|
|
34
|
+
if (this.channel) this.channel.perform("ping")
|
|
35
|
+
}, 15000)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
stopPinging() {
|
|
39
|
+
if (this.pingInterval) {
|
|
40
|
+
clearInterval(this.pingInterval)
|
|
41
|
+
this.pingInterval = null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|