coplan-engine 0.1.3 → 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 +26 -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 +99 -161
- data/app/jobs/coplan/automated_review_job.rb +4 -4
- data/app/models/coplan/comment_thread.rb +13 -7
- data/app/policies/coplan/comment_thread_policy.rb +1 -1
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
- 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", "occurrenceInput", "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) {
|
|
@@ -72,14 +80,9 @@ export default class extends Controller {
|
|
|
72
80
|
: this.selectedText
|
|
73
81
|
this.anchorPreviewTarget.style.display = "block"
|
|
74
82
|
|
|
75
|
-
// Position
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const offsetTop = popoverRect.top - layoutRect.top
|
|
79
|
-
this.formTarget.style.position = "absolute"
|
|
80
|
-
this.formTarget.style.top = `${offsetTop}px`
|
|
81
|
-
|
|
82
|
-
// 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
|
|
83
86
|
this.formTarget.style.display = "block"
|
|
84
87
|
this.popoverTarget.style.display = "none"
|
|
85
88
|
|
|
@@ -129,7 +132,6 @@ export default class extends Controller {
|
|
|
129
132
|
const anchor = event.currentTarget.dataset.anchor
|
|
130
133
|
if (!anchor) return
|
|
131
134
|
|
|
132
|
-
const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
|
|
133
135
|
const occurrence = event.currentTarget.dataset.anchorOccurrence
|
|
134
136
|
|
|
135
137
|
// Remove existing highlights first
|
|
@@ -140,13 +142,55 @@ export default class extends Controller {
|
|
|
140
142
|
// Build full text for position lookups
|
|
141
143
|
this.fullText = this.contentTarget.textContent
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
const highlighted = this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight--active", null)
|
|
145
|
+
const highlighted = this.findAndHighlight(anchor, occurrence, "anchor-highlight--active")
|
|
145
146
|
if (highlighted) {
|
|
146
147
|
highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
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
|
+
|
|
150
194
|
extractContext(range, selectedText) {
|
|
151
195
|
// Grab surrounding text for disambiguation
|
|
152
196
|
const fullText = this.contentTarget.textContent
|
|
@@ -214,105 +258,63 @@ export default class extends Controller {
|
|
|
214
258
|
})
|
|
215
259
|
this.contentTarget.normalize()
|
|
216
260
|
|
|
261
|
+
// Clear margin dots
|
|
262
|
+
if (this.hasMarginTarget) {
|
|
263
|
+
this.marginTarget.innerHTML = ""
|
|
264
|
+
}
|
|
265
|
+
|
|
217
266
|
// Build full text once for position lookups
|
|
218
267
|
this.fullText = this.contentTarget.textContent
|
|
219
268
|
|
|
220
269
|
const threads = this.element.querySelectorAll("[data-anchor-text]")
|
|
221
270
|
threads.forEach(thread => {
|
|
222
271
|
const anchor = thread.dataset.anchorText
|
|
223
|
-
const context = thread.dataset.anchorContext
|
|
224
272
|
const occurrence = thread.dataset.anchorOccurrence
|
|
273
|
+
const status = thread.dataset.threadStatus || "pending"
|
|
274
|
+
const threadId = thread.id
|
|
275
|
+
|
|
225
276
|
if (anchor && anchor.length > 0) {
|
|
226
|
-
|
|
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
|
+
}
|
|
227
292
|
}
|
|
228
293
|
})
|
|
229
|
-
|
|
230
|
-
this.positionThreads()
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
|
|
234
|
-
observeThreadLists() {
|
|
235
|
-
this.threadListObserver = new MutationObserver(() => {
|
|
236
|
-
// Debounce — multiple mutations may fire in quick succession
|
|
237
|
-
clearTimeout(this._repositionTimer)
|
|
238
|
-
this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
this.element.querySelectorAll(".comment-threads-list").forEach(list => {
|
|
242
|
-
this.threadListObserver.observe(list, { childList: true })
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
repositionThreads() {
|
|
247
|
-
// Small delay to let the tab panel become visible before measuring positions
|
|
248
|
-
setTimeout(() => this.positionThreads(), 10)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
positionThreads() {
|
|
252
|
-
const sidebar = this.element.querySelector(".plan-layout__sidebar")
|
|
253
|
-
if (!sidebar) return
|
|
254
|
-
|
|
255
|
-
// Pause observer while reordering DOM to avoid triggering a rehighlight loop
|
|
256
|
-
if (this.threadListObserver) this.threadListObserver.disconnect()
|
|
257
|
-
|
|
258
|
-
this.positionThreadList("#comment-threads", sidebar)
|
|
259
|
-
this.positionThreadList("#resolved-comment-threads", sidebar)
|
|
260
|
-
|
|
261
|
-
// Re-observe after reordering
|
|
262
|
-
if (this.threadListObserver) {
|
|
263
|
-
this.element.querySelectorAll(".comment-threads-list").forEach(list => {
|
|
264
|
-
this.threadListObserver.observe(list, { childList: true })
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
294
|
}
|
|
268
295
|
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const yB = markB ? markB.getBoundingClientRect().top : Infinity
|
|
284
|
-
return yA - yB
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
// Reorder DOM within this list only
|
|
288
|
-
threads.forEach(thread => threadList.appendChild(thread))
|
|
289
|
-
|
|
290
|
-
// Position threads vertically
|
|
291
|
-
const gap = 8
|
|
292
|
-
let cursor = 0
|
|
293
|
-
|
|
294
|
-
threads.forEach(thread => {
|
|
295
|
-
const mark = thread._highlightMark
|
|
296
|
-
let desiredY = cursor
|
|
297
|
-
|
|
298
|
-
if (mark) {
|
|
299
|
-
desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const y = Math.max(desiredY, cursor)
|
|
303
|
-
thread.style.marginTop = `${y - cursor}px`
|
|
304
|
-
cursor = y + thread.offsetHeight + gap
|
|
305
|
-
})
|
|
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)
|
|
306
310
|
}
|
|
307
311
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
findAndHighlightForThread(text, context, occurrence, className, threadEl) {
|
|
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) {
|
|
312
315
|
const fullText = this.fullText
|
|
313
316
|
let targetIndex = -1
|
|
314
317
|
|
|
315
|
-
// Strategy 1: Use the occurrence index from the server (most reliable)
|
|
316
318
|
if (occurrence !== undefined && occurrence !== "") {
|
|
317
319
|
const occurrenceNum = parseInt(occurrence, 10)
|
|
318
320
|
if (!isNaN(occurrenceNum)) {
|
|
@@ -320,27 +322,9 @@ export default class extends Controller {
|
|
|
320
322
|
}
|
|
321
323
|
}
|
|
322
324
|
|
|
323
|
-
// Strategy 2: Fall back to context matching
|
|
324
|
-
if (targetIndex === -1 && context && context.length > 0) {
|
|
325
|
-
const contextIndex = fullText.indexOf(context)
|
|
326
|
-
if (contextIndex !== -1) {
|
|
327
|
-
targetIndex = fullText.indexOf(text, contextIndex)
|
|
328
|
-
if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
|
|
329
|
-
targetIndex = fullText.indexOf(text)
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Strategy 3: Fall back to first occurrence
|
|
335
|
-
if (targetIndex === -1) {
|
|
336
|
-
targetIndex = fullText.indexOf(text)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
325
|
if (targetIndex === -1) return null
|
|
340
326
|
|
|
341
|
-
|
|
342
|
-
if (mark && threadEl) threadEl._highlightMark = mark
|
|
343
|
-
return mark
|
|
327
|
+
return this.highlightAtIndex(targetIndex, text.length, className)
|
|
344
328
|
}
|
|
345
329
|
|
|
346
330
|
findNthOccurrence(text, search, n) {
|
|
@@ -352,43 +336,6 @@ export default class extends Controller {
|
|
|
352
336
|
return pos
|
|
353
337
|
}
|
|
354
338
|
|
|
355
|
-
findAndHighlightWithContext(text, context, className) {
|
|
356
|
-
// Use context to find the right occurrence of the anchor text
|
|
357
|
-
const fullText = this.fullText
|
|
358
|
-
let targetIndex
|
|
359
|
-
|
|
360
|
-
if (context && context.length > 0) {
|
|
361
|
-
const contextIndex = fullText.indexOf(context)
|
|
362
|
-
if (contextIndex !== -1) {
|
|
363
|
-
// Find the anchor text within the context region
|
|
364
|
-
targetIndex = fullText.indexOf(text, contextIndex)
|
|
365
|
-
if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
|
|
366
|
-
targetIndex = fullText.indexOf(text) // fallback
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
369
|
-
targetIndex = fullText.indexOf(text)
|
|
370
|
-
}
|
|
371
|
-
} else {
|
|
372
|
-
targetIndex = fullText.indexOf(text)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (targetIndex === -1) return null
|
|
376
|
-
|
|
377
|
-
// Find the thread element to store the mark reference
|
|
378
|
-
const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
|
|
379
|
-
let threadEl = null
|
|
380
|
-
for (const t of threads) {
|
|
381
|
-
if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
|
|
382
|
-
threadEl = t
|
|
383
|
-
break
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const mark = this.highlightAtIndex(targetIndex, text.length, className)
|
|
388
|
-
if (mark && threadEl) threadEl._highlightMark = mark
|
|
389
|
-
return mark
|
|
390
|
-
}
|
|
391
|
-
|
|
392
339
|
highlightAtIndex(startIndex, length, className) {
|
|
393
340
|
if (startIndex < 0 || length <= 0) return null
|
|
394
341
|
|
|
@@ -433,13 +380,4 @@ export default class extends Controller {
|
|
|
433
380
|
|
|
434
381
|
return firstHighlighted
|
|
435
382
|
}
|
|
436
|
-
|
|
437
|
-
// Keep legacy method for scrollToAnchor (single-use highlight)
|
|
438
|
-
findAndHighlight(text, className) {
|
|
439
|
-
if (!text || text.length === 0) return null
|
|
440
|
-
const fullText = this.contentTarget.textContent
|
|
441
|
-
const index = fullText.indexOf(text)
|
|
442
|
-
if (index === -1) return null
|
|
443
|
-
return this.highlightAtIndex(index, text.length, className)
|
|
444
|
-
}
|
|
445
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?
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
<blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
|
|
9
9
|
</div>
|
|
10
10
|
<div class="form-group">
|
|
11
|
-
<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>
|
|
12
13
|
</div>
|
|
13
14
|
<div class="comment-form__actions">
|
|
14
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 %>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
data-thread-id="<%= thread.id %>">
|
|
6
6
|
<div class="comment-thread__header">
|
|
7
7
|
<div class="comment-thread__meta">
|
|
8
|
-
<span class="badge badge--<%= thread.
|
|
8
|
+
<span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
|
|
9
9
|
<% if thread.out_of_date? %>
|
|
10
10
|
<span class="badge badge--abandoned">out of date</span>
|
|
11
11
|
<% end %>
|
|
@@ -26,16 +26,13 @@
|
|
|
26
26
|
<% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
|
|
27
27
|
<% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
|
|
28
28
|
|
|
29
|
-
<% if thread.
|
|
29
|
+
<% if thread.open? %>
|
|
30
30
|
<%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
|
|
31
31
|
|
|
32
32
|
<div class="comment-thread__actions">
|
|
33
|
-
<% if is_plan_author || is_thread_author %>
|
|
34
|
-
<%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
35
|
-
<% end %>
|
|
36
33
|
<% if is_plan_author %>
|
|
37
34
|
<%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
38
|
-
<%= link_to "
|
|
35
|
+
<%= link_to "Discard", discard_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
|
|
39
36
|
<% end %>
|
|
40
37
|
</div>
|
|
41
38
|
<% else %>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<div class="thread-popover-data"
|
|
2
|
+
id="<%= dom_id(thread) %>"
|
|
3
|
+
data-anchor-text="<%= thread.anchor_text %>"
|
|
4
|
+
data-anchor-occurrence="<%= thread.anchor_occurrence_index %>"
|
|
5
|
+
data-thread-id="<%= thread.id %>"
|
|
6
|
+
data-thread-status="<%= thread.status %>">
|
|
7
|
+
|
|
8
|
+
<div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
|
|
9
|
+
<div class="thread-popover__header">
|
|
10
|
+
<span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
|
|
11
|
+
<% if thread.out_of_date? %>
|
|
12
|
+
<span class="badge badge--abandoned">out of date</span>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<% if thread.anchored? %>
|
|
17
|
+
<blockquote class="thread-popover__quote"><%= thread.anchor_preview %></blockquote>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<div class="thread-popover__comments" id="<%= dom_id(thread, :comments) %>">
|
|
21
|
+
<% thread.comments.each do |comment| %>
|
|
22
|
+
<%= render partial: "coplan/comments/comment", locals: { comment: comment } %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<%# current_user may not be available during Turbo Stream broadcasts %>
|
|
27
|
+
<% viewer = local_assigns.fetch(:current_user, nil) %>
|
|
28
|
+
<% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer&.id %>
|
|
29
|
+
<% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer&.id %>
|
|
30
|
+
|
|
31
|
+
<% if thread.open? %>
|
|
32
|
+
<div class="thread-popover__reply">
|
|
33
|
+
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
34
|
+
<div class="form-group">
|
|
35
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
|
|
36
|
+
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
37
|
+
</div>
|
|
38
|
+
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="thread-popover__actions">
|
|
43
|
+
<% if is_plan_author %>
|
|
44
|
+
<% if thread.status == "pending" %>
|
|
45
|
+
<%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
46
|
+
<% end %>
|
|
47
|
+
<%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|
|
50
|
+
<% else %>
|
|
51
|
+
<% if is_plan_author || is_thread_author %>
|
|
52
|
+
<div class="thread-popover__actions">
|
|
53
|
+
<%= button_to "Reopen", reopen_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|