coplan-engine 0.1.2 → 0.2.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 +212 -85
- data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
- data/app/controllers/coplan/comment_threads_controller.rb +27 -72
- data/app/controllers/coplan/plans_controller.rb +1 -3
- data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
- data/app/javascript/controllers/coplan/comment_nav_controller.js +134 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +138 -131
- data/app/jobs/coplan/automated_review_job.rb +4 -4
- data/app/models/coplan/comment_thread.rb +32 -7
- data/app/policies/coplan/comment_thread_policy.rb +1 -1
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +3 -1
- data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_thread.html.erb +5 -7
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
- data/app/views/coplan/plans/show.html.erb +22 -30
- data/app/views/layouts/coplan/application.html.erb +3 -0
- data/config/routes.rb +2 -2
- data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
- data/lib/coplan/version.rb +1 -1
- metadata +5 -2
- data/app/javascript/controllers/coplan/tabs_controller.js +0 -18
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["position", "resolvedToggle"]
|
|
5
|
+
static values = { planId: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.currentIndex = -1
|
|
9
|
+
this.updatePosition()
|
|
10
|
+
this.handleKeydown = this.handleKeydown.bind(this)
|
|
11
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
handleKeydown(event) {
|
|
19
|
+
// Don't intercept when typing in inputs/textareas or when modifier keys are held
|
|
20
|
+
const tag = event.target.tagName
|
|
21
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
|
|
22
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
23
|
+
|
|
24
|
+
switch (event.key) {
|
|
25
|
+
case "j":
|
|
26
|
+
case "ArrowDown":
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
this.next()
|
|
29
|
+
break
|
|
30
|
+
case "k":
|
|
31
|
+
case "ArrowUp":
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
this.prev()
|
|
34
|
+
break
|
|
35
|
+
case "r":
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
this.focusReply()
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get openHighlights() {
|
|
43
|
+
return Array.from(document.querySelectorAll("mark.anchor-highlight--open"))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get allHighlights() {
|
|
47
|
+
return Array.from(document.querySelectorAll("mark.anchor-highlight"))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next() {
|
|
51
|
+
const highlights = this.openHighlights
|
|
52
|
+
if (highlights.length === 0) return
|
|
53
|
+
|
|
54
|
+
this.currentIndex = (this.currentIndex + 1) % highlights.length
|
|
55
|
+
this.navigateTo(highlights[this.currentIndex])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
prev() {
|
|
59
|
+
const highlights = this.openHighlights
|
|
60
|
+
if (highlights.length === 0) return
|
|
61
|
+
|
|
62
|
+
this.currentIndex = this.currentIndex <= 0 ? highlights.length - 1 : this.currentIndex - 1
|
|
63
|
+
this.navigateTo(highlights[this.currentIndex])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
navigateTo(mark) {
|
|
67
|
+
// Remove active highlight from all marks
|
|
68
|
+
document.querySelectorAll(".anchor-highlight--active").forEach(el => {
|
|
69
|
+
el.classList.remove("anchor-highlight--active")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Add active class and scroll into view
|
|
73
|
+
mark.classList.add("anchor-highlight--active")
|
|
74
|
+
mark.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
75
|
+
|
|
76
|
+
// Open the thread popover if there's one
|
|
77
|
+
const threadId = mark.dataset.threadId
|
|
78
|
+
if (threadId) {
|
|
79
|
+
const popover = document.getElementById(`${threadId}_popover`)
|
|
80
|
+
if (popover) {
|
|
81
|
+
const markRect = mark.getBoundingClientRect()
|
|
82
|
+
popover.showPopover()
|
|
83
|
+
popover.style.top = `${markRect.top}px`
|
|
84
|
+
popover.style.left = `${markRect.right + 12}px`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.updatePosition()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
focusReply() {
|
|
92
|
+
let openPopover
|
|
93
|
+
try {
|
|
94
|
+
openPopover = document.querySelector(".thread-popover:popover-open")
|
|
95
|
+
} catch {
|
|
96
|
+
// :popover-open not supported — find the visible popover manually
|
|
97
|
+
openPopover = Array.from(document.querySelectorAll(".thread-popover[popover]"))
|
|
98
|
+
.find(el => el.checkVisibility?.())
|
|
99
|
+
}
|
|
100
|
+
if (!openPopover) return
|
|
101
|
+
|
|
102
|
+
const textarea = openPopover.querySelector(".thread-popover__reply textarea")
|
|
103
|
+
if (textarea) {
|
|
104
|
+
textarea.focus({ preventScroll: true })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
toggleResolved() {
|
|
109
|
+
const planLayout = document.querySelector(".plan-layout")
|
|
110
|
+
if (!planLayout) return
|
|
111
|
+
|
|
112
|
+
if (this.resolvedToggleTarget.checked) {
|
|
113
|
+
planLayout.classList.add("plan-layout--show-resolved")
|
|
114
|
+
} else {
|
|
115
|
+
planLayout.classList.remove("plan-layout--show-resolved")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
updatePosition() {
|
|
120
|
+
if (!this.hasPositionTarget) return
|
|
121
|
+
|
|
122
|
+
const highlights = this.openHighlights
|
|
123
|
+
if (highlights.length === 0) {
|
|
124
|
+
this.positionTarget.textContent = ""
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (this.currentIndex < 0) {
|
|
129
|
+
this.positionTarget.textContent = `${highlights.length} total`
|
|
130
|
+
} else {
|
|
131
|
+
this.positionTarget.textContent = `${this.currentIndex + 1} of ${highlights.length}`
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
-
static targets = ["content", "popover", "form", "anchorInput", "contextInput", "anchorPreview", "anchorQuote"]
|
|
4
|
+
static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote", "margin", "threads"]
|
|
5
5
|
static values = { planId: String }
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
@@ -9,13 +9,21 @@ export default class extends Controller {
|
|
|
9
9
|
this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
10
10
|
document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
11
11
|
this.highlightAnchors()
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
// Watch for broadcast-appended threads and re-highlight
|
|
14
|
+
if (this.hasThreadsTarget) {
|
|
15
|
+
this._threadsObserver = new MutationObserver(() => this.highlightAnchors())
|
|
16
|
+
this._threadsObserver.observe(this.threadsTarget, { childList: true })
|
|
17
|
+
}
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
disconnect() {
|
|
16
21
|
this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
17
22
|
document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
18
|
-
if (this.
|
|
23
|
+
if (this._threadsObserver) {
|
|
24
|
+
this._threadsObserver.disconnect()
|
|
25
|
+
this._threadsObserver = null
|
|
26
|
+
}
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
handleMouseUp(event) {
|
|
@@ -48,6 +56,7 @@ export default class extends Controller {
|
|
|
48
56
|
|
|
49
57
|
this.selectedText = text
|
|
50
58
|
this.selectedContext = this.extractContext(range, text)
|
|
59
|
+
this.selectedOccurrence = this.computeOccurrence(range, text)
|
|
51
60
|
|
|
52
61
|
// Position popover near the selection
|
|
53
62
|
const rect = range.getBoundingClientRect()
|
|
@@ -62,22 +71,18 @@ export default class extends Controller {
|
|
|
62
71
|
event.preventDefault()
|
|
63
72
|
if (!this.selectedText) return
|
|
64
73
|
|
|
65
|
-
// Set the anchor text and
|
|
74
|
+
// Set the anchor text, surrounding context, and occurrence index
|
|
66
75
|
this.anchorInputTarget.value = this.selectedText
|
|
67
76
|
this.contextInputTarget.value = this.selectedContext || ""
|
|
77
|
+
this.occurrenceInputTarget.value = this.selectedOccurrence != null ? this.selectedOccurrence : ""
|
|
68
78
|
this.anchorQuoteTarget.textContent = this.selectedText.length > 120
|
|
69
79
|
? this.selectedText.substring(0, 120) + "…"
|
|
70
80
|
: this.selectedText
|
|
71
81
|
this.anchorPreviewTarget.style.display = "block"
|
|
72
82
|
|
|
73
|
-
// Position
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const offsetTop = popoverRect.top - layoutRect.top
|
|
77
|
-
this.formTarget.style.position = "absolute"
|
|
78
|
-
this.formTarget.style.top = `${offsetTop}px`
|
|
79
|
-
|
|
80
|
-
// Show form, hide popover
|
|
83
|
+
// Position form where the popover was, then show it
|
|
84
|
+
this.formTarget.style.top = this.popoverTarget.style.top
|
|
85
|
+
this.formTarget.style.left = this.popoverTarget.style.left
|
|
81
86
|
this.formTarget.style.display = "block"
|
|
82
87
|
this.popoverTarget.style.display = "none"
|
|
83
88
|
|
|
@@ -114,33 +119,78 @@ export default class extends Controller {
|
|
|
114
119
|
this.formTarget.style.display = "none"
|
|
115
120
|
this.anchorInputTarget.value = ""
|
|
116
121
|
this.contextInputTarget.value = ""
|
|
122
|
+
this.occurrenceInputTarget.value = ""
|
|
117
123
|
this.anchorPreviewTarget.style.display = "none"
|
|
118
124
|
const textarea = this.formTarget.querySelector("textarea")
|
|
119
125
|
if (textarea) textarea.value = ""
|
|
120
126
|
this.selectedText = null
|
|
121
127
|
this.selectedContext = null
|
|
128
|
+
this.selectedOccurrence = null
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
scrollToAnchor(event) {
|
|
125
132
|
const anchor = event.currentTarget.dataset.anchor
|
|
126
133
|
if (!anchor) return
|
|
127
134
|
|
|
128
|
-
const
|
|
135
|
+
const occurrence = event.currentTarget.dataset.anchorOccurrence
|
|
129
136
|
|
|
130
137
|
// Remove existing highlights first
|
|
131
138
|
this.contentTarget.querySelectorAll(".anchor-highlight--active").forEach(el => {
|
|
132
139
|
el.classList.remove("anchor-highlight--active")
|
|
133
140
|
})
|
|
134
141
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
// Build full text for position lookups
|
|
143
|
+
this.fullText = this.contentTarget.textContent
|
|
144
|
+
|
|
145
|
+
const highlighted = this.findAndHighlight(anchor, occurrence, "anchor-highlight--active")
|
|
139
146
|
if (highlighted) {
|
|
140
147
|
highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
openThreadPopover(event) {
|
|
152
|
+
const threadId = event.currentTarget.dataset.threadId
|
|
153
|
+
if (!threadId) return
|
|
154
|
+
|
|
155
|
+
const popover = document.getElementById(`${threadId}_popover`)
|
|
156
|
+
if (!popover) return
|
|
157
|
+
|
|
158
|
+
// Position the popover near the clicked element
|
|
159
|
+
const trigger = event.currentTarget
|
|
160
|
+
const triggerRect = trigger.getBoundingClientRect()
|
|
161
|
+
|
|
162
|
+
// Hide visually while positioning to prevent flash
|
|
163
|
+
popover.style.visibility = "hidden"
|
|
164
|
+
popover.showPopover()
|
|
165
|
+
|
|
166
|
+
// Position after showing (popover needs to be in top layer first)
|
|
167
|
+
const popoverRect = popover.getBoundingClientRect()
|
|
168
|
+
const viewportWidth = window.innerWidth
|
|
169
|
+
const viewportHeight = window.innerHeight
|
|
170
|
+
|
|
171
|
+
// Default: right of the content area, aligned with the trigger
|
|
172
|
+
let top = triggerRect.top
|
|
173
|
+
let left = triggerRect.right + 12
|
|
174
|
+
|
|
175
|
+
// If it would overflow right, position to the left
|
|
176
|
+
if (left + popoverRect.width > viewportWidth - 16) {
|
|
177
|
+
left = triggerRect.left - popoverRect.width - 12
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If it would overflow bottom, shift up
|
|
181
|
+
if (top + popoverRect.height > viewportHeight - 16) {
|
|
182
|
+
top = viewportHeight - popoverRect.height - 16
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Ensure it doesn't go outside viewport
|
|
186
|
+
if (top < 16) top = 16
|
|
187
|
+
if (left < 16) left = 16
|
|
188
|
+
|
|
189
|
+
popover.style.top = `${top}px`
|
|
190
|
+
popover.style.left = `${left}px`
|
|
191
|
+
popover.style.visibility = "visible"
|
|
192
|
+
}
|
|
193
|
+
|
|
144
194
|
extractContext(range, selectedText) {
|
|
145
195
|
// Grab surrounding text for disambiguation
|
|
146
196
|
const fullText = this.contentTarget.textContent
|
|
@@ -166,6 +216,21 @@ export default class extends Controller {
|
|
|
166
216
|
return fullText.slice(start, end)
|
|
167
217
|
}
|
|
168
218
|
|
|
219
|
+
// Computes the 1-based occurrence number of the selected text in the DOM content.
|
|
220
|
+
// This is sent to the server so resolve_anchor_position picks the right match.
|
|
221
|
+
computeOccurrence(range, text) {
|
|
222
|
+
const offset = this.getSelectionOffset(range)
|
|
223
|
+
const fullText = this.contentTarget.textContent
|
|
224
|
+
|
|
225
|
+
let count = 0
|
|
226
|
+
let pos = -1
|
|
227
|
+
while ((pos = fullText.indexOf(text, pos + 1)) !== -1) {
|
|
228
|
+
count++
|
|
229
|
+
if (pos >= offset) return count
|
|
230
|
+
}
|
|
231
|
+
return count > 0 ? count : 1
|
|
232
|
+
}
|
|
233
|
+
|
|
169
234
|
getSelectionOffset(range) {
|
|
170
235
|
if (!range || !this.contentTarget) return 0
|
|
171
236
|
|
|
@@ -193,131 +258,82 @@ export default class extends Controller {
|
|
|
193
258
|
})
|
|
194
259
|
this.contentTarget.normalize()
|
|
195
260
|
|
|
261
|
+
// Clear margin dots
|
|
262
|
+
if (this.hasMarginTarget) {
|
|
263
|
+
this.marginTarget.innerHTML = ""
|
|
264
|
+
}
|
|
265
|
+
|
|
196
266
|
// Build full text once for position lookups
|
|
197
267
|
this.fullText = this.contentTarget.textContent
|
|
198
268
|
|
|
199
269
|
const threads = this.element.querySelectorAll("[data-anchor-text]")
|
|
200
270
|
threads.forEach(thread => {
|
|
201
271
|
const anchor = thread.dataset.anchorText
|
|
202
|
-
const
|
|
272
|
+
const occurrence = thread.dataset.anchorOccurrence
|
|
273
|
+
const status = thread.dataset.threadStatus || "pending"
|
|
274
|
+
const threadId = thread.id
|
|
275
|
+
|
|
203
276
|
if (anchor && anchor.length > 0) {
|
|
204
|
-
|
|
277
|
+
const isOpen = status === "pending" || status === "todo"
|
|
278
|
+
const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
|
|
279
|
+
const mark = this.findAndHighlight(anchor, occurrence, `anchor-highlight ${statusClass}`)
|
|
280
|
+
|
|
281
|
+
if (mark && threadId) {
|
|
282
|
+
// Make highlight clickable to open popover
|
|
283
|
+
mark.dataset.threadId = threadId
|
|
284
|
+
mark.style.cursor = "pointer"
|
|
285
|
+
mark.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
286
|
+
|
|
287
|
+
// Create margin dot
|
|
288
|
+
if (this.hasMarginTarget) {
|
|
289
|
+
this.createMarginDot(mark, threadId, status)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
205
292
|
}
|
|
206
293
|
})
|
|
207
|
-
|
|
208
|
-
this.positionThreads()
|
|
209
294
|
}
|
|
210
295
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Small delay to let the tab panel become visible before measuring positions
|
|
226
|
-
setTimeout(() => this.positionThreads(), 10)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
positionThreads() {
|
|
230
|
-
const sidebar = this.element.querySelector(".plan-layout__sidebar")
|
|
231
|
-
if (!sidebar) return
|
|
232
|
-
|
|
233
|
-
// Pause observer while reordering DOM to avoid triggering a rehighlight loop
|
|
234
|
-
if (this.threadListObserver) this.threadListObserver.disconnect()
|
|
235
|
-
|
|
236
|
-
this.positionThreadList("#comment-threads", sidebar)
|
|
237
|
-
this.positionThreadList("#resolved-comment-threads", sidebar)
|
|
238
|
-
|
|
239
|
-
// Re-observe after reordering
|
|
240
|
-
if (this.threadListObserver) {
|
|
241
|
-
this.element.querySelectorAll(".comment-threads-list").forEach(list => {
|
|
242
|
-
this.threadListObserver.observe(list, { childList: true })
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
positionThreadList(selector, sidebar) {
|
|
248
|
-
const threadList = this.element.querySelector(selector)
|
|
249
|
-
if (!threadList) return
|
|
250
|
-
|
|
251
|
-
const threads = Array.from(threadList.querySelectorAll(".comment-thread"))
|
|
252
|
-
if (threads.length === 0) return
|
|
253
|
-
|
|
254
|
-
const sidebarRect = sidebar.getBoundingClientRect()
|
|
255
|
-
|
|
256
|
-
// Sort threads by their anchor's vertical position in the document
|
|
257
|
-
threads.sort((a, b) => {
|
|
258
|
-
const markA = a._highlightMark
|
|
259
|
-
const markB = b._highlightMark
|
|
260
|
-
const yA = markA ? markA.getBoundingClientRect().top : Infinity
|
|
261
|
-
const yB = markB ? markB.getBoundingClientRect().top : Infinity
|
|
262
|
-
return yA - yB
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
// Reorder DOM within this list only
|
|
266
|
-
threads.forEach(thread => threadList.appendChild(thread))
|
|
267
|
-
|
|
268
|
-
// Position threads vertically
|
|
269
|
-
const gap = 8
|
|
270
|
-
let cursor = 0
|
|
271
|
-
|
|
272
|
-
threads.forEach(thread => {
|
|
273
|
-
const mark = thread._highlightMark
|
|
274
|
-
let desiredY = cursor
|
|
275
|
-
|
|
276
|
-
if (mark) {
|
|
277
|
-
desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const y = Math.max(desiredY, cursor)
|
|
281
|
-
thread.style.marginTop = `${y - cursor}px`
|
|
282
|
-
cursor = y + thread.offsetHeight + gap
|
|
283
|
-
})
|
|
296
|
+
createMarginDot(highlightMark, threadId, status) {
|
|
297
|
+
const contentRect = this.contentTarget.getBoundingClientRect()
|
|
298
|
+
const markRect = highlightMark.getBoundingClientRect()
|
|
299
|
+
const marginRect = this.marginTarget.getBoundingClientRect()
|
|
300
|
+
|
|
301
|
+
const dot = document.createElement("button")
|
|
302
|
+
const isOpen = status === "pending" || status === "todo"
|
|
303
|
+
dot.className = `margin-dot margin-dot--${isOpen ? "open" : "resolved"}`
|
|
304
|
+
dot.style.top = `${markRect.top - marginRect.top}px`
|
|
305
|
+
dot.dataset.threadId = threadId
|
|
306
|
+
dot.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
307
|
+
dot.title = `${status} comment`
|
|
308
|
+
|
|
309
|
+
this.marginTarget.appendChild(dot)
|
|
284
310
|
}
|
|
285
311
|
|
|
286
|
-
|
|
287
|
-
|
|
312
|
+
// Find and highlight the Nth occurrence of text in the rendered DOM.
|
|
313
|
+
// Uses the occurrence index from server-side positional data.
|
|
314
|
+
findAndHighlight(text, occurrence, className) {
|
|
288
315
|
const fullText = this.fullText
|
|
289
|
-
let targetIndex
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
targetIndex = fullText.indexOf(text, contextIndex)
|
|
296
|
-
if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
|
|
297
|
-
targetIndex = fullText.indexOf(text) // fallback
|
|
298
|
-
}
|
|
299
|
-
} else {
|
|
300
|
-
targetIndex = fullText.indexOf(text)
|
|
316
|
+
let targetIndex = -1
|
|
317
|
+
|
|
318
|
+
if (occurrence !== undefined && occurrence !== "") {
|
|
319
|
+
const occurrenceNum = parseInt(occurrence, 10)
|
|
320
|
+
if (!isNaN(occurrenceNum)) {
|
|
321
|
+
targetIndex = this.findNthOccurrence(fullText, text, occurrenceNum)
|
|
301
322
|
}
|
|
302
|
-
} else {
|
|
303
|
-
targetIndex = fullText.indexOf(text)
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
if (targetIndex === -1) return null
|
|
307
326
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
let threadEl = null
|
|
311
|
-
for (const t of threads) {
|
|
312
|
-
if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
|
|
313
|
-
threadEl = t
|
|
314
|
-
break
|
|
315
|
-
}
|
|
316
|
-
}
|
|
327
|
+
return this.highlightAtIndex(targetIndex, text.length, className)
|
|
328
|
+
}
|
|
317
329
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
findNthOccurrence(text, search, n) {
|
|
331
|
+
let pos = -1
|
|
332
|
+
for (let i = 0; i <= n; i++) {
|
|
333
|
+
pos = text.indexOf(search, pos + 1)
|
|
334
|
+
if (pos === -1) return -1
|
|
335
|
+
}
|
|
336
|
+
return pos
|
|
321
337
|
}
|
|
322
338
|
|
|
323
339
|
highlightAtIndex(startIndex, length, className) {
|
|
@@ -364,13 +380,4 @@ export default class extends Controller {
|
|
|
364
380
|
|
|
365
381
|
return firstHighlighted
|
|
366
382
|
}
|
|
367
|
-
|
|
368
|
-
// Keep legacy method for scrollToAnchor (single-use highlight)
|
|
369
|
-
findAndHighlight(text, className) {
|
|
370
|
-
if (!text || text.length === 0) return null
|
|
371
|
-
const fullText = this.contentTarget.textContent
|
|
372
|
-
const index = fullText.indexOf(text)
|
|
373
|
-
if (index === -1) return null
|
|
374
|
-
return this.highlightAtIndex(index, text.length, className)
|
|
375
|
-
}
|
|
376
383
|
}
|
|
@@ -46,7 +46,7 @@ module CoPlan
|
|
|
46
46
|
plan_version: version,
|
|
47
47
|
created_by_user: created_by,
|
|
48
48
|
anchor_text: item[:anchor_text],
|
|
49
|
-
status: "
|
|
49
|
+
status: "pending"
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
thread.comments.create!(
|
|
@@ -60,10 +60,10 @@ module CoPlan
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def broadcast_new_thread(plan, thread)
|
|
63
|
-
Broadcaster.
|
|
63
|
+
Broadcaster.append_to(
|
|
64
64
|
plan,
|
|
65
|
-
target: "
|
|
66
|
-
partial: "coplan/comment_threads/
|
|
65
|
+
target: "plan-threads",
|
|
66
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
67
67
|
locals: { thread: thread, plan: plan }
|
|
68
68
|
)
|
|
69
69
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module CoPlan
|
|
2
2
|
class CommentThread < ApplicationRecord
|
|
3
|
-
STATUSES = %w[
|
|
3
|
+
STATUSES = %w[pending todo resolved discarded].freeze
|
|
4
|
+
OPEN_STATUSES = %w[pending todo].freeze
|
|
5
|
+
CLOSED_STATUSES = %w[resolved discarded].freeze
|
|
4
6
|
|
|
5
7
|
attr_accessor :anchor_occurrence
|
|
6
8
|
|
|
@@ -16,10 +18,10 @@ module CoPlan
|
|
|
16
18
|
|
|
17
19
|
before_create :resolve_anchor_position
|
|
18
20
|
|
|
19
|
-
scope :open_threads, -> { where(status:
|
|
21
|
+
scope :open_threads, -> { where(status: OPEN_STATUSES) }
|
|
20
22
|
scope :current, -> { where(out_of_date: false) }
|
|
21
|
-
scope :active, -> { where(status:
|
|
22
|
-
scope :archived, -> { where("status
|
|
23
|
+
scope :active, -> { where(status: OPEN_STATUSES, out_of_date: false) }
|
|
24
|
+
scope :archived, -> { where("status NOT IN (?) OR out_of_date = ?", OPEN_STATUSES, true) }
|
|
23
25
|
|
|
24
26
|
# Transforms anchor positions through intervening version edits using OT.
|
|
25
27
|
# Threads without positional data (anchor_start/anchor_end/anchor_revision)
|
|
@@ -94,11 +96,15 @@ module CoPlan
|
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
def accept!(user)
|
|
97
|
-
update!(status: "
|
|
99
|
+
update!(status: "todo", resolved_by_user: user)
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
def
|
|
101
|
-
update!(status: "
|
|
102
|
+
def discard!(user)
|
|
103
|
+
update!(status: "discarded", resolved_by_user: user)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def open?
|
|
107
|
+
OPEN_STATUSES.include?(status)
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def anchor_valid?
|
|
@@ -106,6 +112,25 @@ module CoPlan
|
|
|
106
112
|
!out_of_date
|
|
107
113
|
end
|
|
108
114
|
|
|
115
|
+
# Returns the 0-based occurrence index of anchor_text in the raw markdown,
|
|
116
|
+
# computed from anchor_start. The frontend uses this to find the correct
|
|
117
|
+
# occurrence in the rendered DOM text instead of relying on context matching.
|
|
118
|
+
def anchor_occurrence_index
|
|
119
|
+
return nil unless anchored? && anchor_start.present?
|
|
120
|
+
|
|
121
|
+
content = plan.current_content
|
|
122
|
+
return nil unless content.present?
|
|
123
|
+
|
|
124
|
+
count = 0
|
|
125
|
+
pos = 0
|
|
126
|
+
while (idx = content.index(anchor_text, pos))
|
|
127
|
+
break if idx >= anchor_start
|
|
128
|
+
count += 1
|
|
129
|
+
pos = idx + 1
|
|
130
|
+
end
|
|
131
|
+
count
|
|
132
|
+
end
|
|
133
|
+
|
|
109
134
|
def anchor_context_with_highlight(chars: 100)
|
|
110
135
|
return nil unless anchored? && anchor_start.present?
|
|
111
136
|
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
<%= form_with url: plan_comment_threads_path(plan), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetCommentForm" } do |f| %>
|
|
3
3
|
<input type="hidden" name="comment_thread[anchor_text]" data-coplan--text-selection-target="anchorInput" value="">
|
|
4
4
|
<input type="hidden" name="comment_thread[anchor_context]" data-coplan--text-selection-target="contextInput" value="">
|
|
5
|
+
<input type="hidden" name="comment_thread[anchor_occurrence]" data-coplan--text-selection-target="occurrenceInput" value="">
|
|
5
6
|
<div class="comment-form__anchor" data-coplan--text-selection-target="anchorPreview" style="display: none;">
|
|
6
7
|
<span class="text-sm text-muted">Commenting on:</span>
|
|
7
8
|
<blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
|
|
8
9
|
</div>
|
|
9
10
|
<div class="form-group">
|
|
10
|
-
<textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required
|
|
11
|
+
<textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required
|
|
12
|
+
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
11
13
|
</div>
|
|
12
14
|
<div class="comment-form__actions">
|
|
13
15
|
<button type="submit" class="btn btn--primary btn--sm">Comment</button>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
|
|
2
2
|
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
3
3
|
<div class="form-group">
|
|
4
|
-
<textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
|
|
4
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
|
|
5
|
+
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
5
6
|
</div>
|
|
6
7
|
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
7
8
|
<% end %>
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<div class="comment-thread card" id="<%= dom_id(thread) %>"
|
|
2
2
|
data-anchor-text="<%= thread.anchor_text %>"
|
|
3
3
|
data-anchor-context="<%= thread.anchor_context %>"
|
|
4
|
+
data-anchor-occurrence="<%= thread.anchor_occurrence_index %>"
|
|
4
5
|
data-thread-id="<%= thread.id %>">
|
|
5
6
|
<div class="comment-thread__header">
|
|
6
7
|
<div class="comment-thread__meta">
|
|
7
|
-
<span class="badge badge--<%= thread.
|
|
8
|
+
<span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
|
|
8
9
|
<% if thread.out_of_date? %>
|
|
9
10
|
<span class="badge badge--abandoned">out of date</span>
|
|
10
11
|
<% end %>
|
|
11
12
|
</div>
|
|
12
13
|
<% if thread.anchored? %>
|
|
13
|
-
<blockquote class="comment-thread__anchor-quote" data-action="click->coplan--text-selection#scrollToAnchor" data-anchor="<%= thread.anchor_text %>" style="cursor: pointer;"><%= thread.anchor_preview %></blockquote>
|
|
14
|
+
<blockquote class="comment-thread__anchor-quote" data-action="click->coplan--text-selection#scrollToAnchor" data-anchor="<%= thread.anchor_text %>" data-anchor-occurrence="<%= thread.anchor_occurrence_index %>" style="cursor: pointer;"><%= thread.anchor_preview %></blockquote>
|
|
14
15
|
<% end %>
|
|
15
16
|
</div>
|
|
16
17
|
|
|
@@ -25,16 +26,13 @@
|
|
|
25
26
|
<% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
|
|
26
27
|
<% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
|
|
27
28
|
|
|
28
|
-
<% if thread.
|
|
29
|
+
<% if thread.open? %>
|
|
29
30
|
<%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
|
|
30
31
|
|
|
31
32
|
<div class="comment-thread__actions">
|
|
32
|
-
<% if is_plan_author || is_thread_author %>
|
|
33
|
-
<%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
34
|
-
<% end %>
|
|
35
33
|
<% if is_plan_author %>
|
|
36
34
|
<%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
37
|
-
<%= link_to "
|
|
35
|
+
<%= link_to "Discard", discard_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
38
36
|
<% end %>
|
|
39
37
|
</div>
|
|
40
38
|
<% else %>
|