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.
@@ -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
- this.observeThreadLists()
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.threadListObserver) this.threadListObserver.disconnect()
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 surrounding context
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 the form in the sidebar at the same vertical level as the selection
74
- const layoutRect = this.element.getBoundingClientRect()
75
- const popoverRect = this.popoverTarget.getBoundingClientRect()
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 context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
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
- // Find and highlight the anchor text using context for disambiguation
136
- const highlighted = context
137
- ? this.findAndHighlightWithContext(anchor, context, "anchor-highlight--active")
138
- : this.findAndHighlight(anchor, "anchor-highlight--active")
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 context = thread.dataset.anchorContext
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
- this.findAndHighlightWithContext(anchor, context, "anchor-highlight")
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
- // Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
212
- observeThreadLists() {
213
- this.threadListObserver = new MutationObserver(() => {
214
- // Debounce multiple mutations may fire in quick succession
215
- clearTimeout(this._repositionTimer)
216
- this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
217
- })
218
-
219
- this.element.querySelectorAll(".comment-threads-list").forEach(list => {
220
- this.threadListObserver.observe(list, { childList: true })
221
- })
222
- }
223
-
224
- repositionThreads() {
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
- findAndHighlightWithContext(text, context, className) {
287
- // Use context to find the right occurrence of the anchor text
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 (context && context.length > 0) {
292
- const contextIndex = fullText.indexOf(context)
293
- if (contextIndex !== -1) {
294
- // Find the anchor text within the context region
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
- // Find the thread element to store the mark reference
309
- const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
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
- const mark = this.highlightAtIndex(targetIndex, text.length, className)
319
- if (mark && threadEl) threadEl._highlightMark = mark
320
- return mark
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: "open"
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.prepend_to(
63
+ Broadcaster.append_to(
64
64
  plan,
65
- target: "comment-threads",
66
- partial: "coplan/comment_threads/thread",
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[open resolved accepted dismissed].freeze
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: "open") }
21
+ scope :open_threads, -> { where(status: OPEN_STATUSES) }
20
22
  scope :current, -> { where(out_of_date: false) }
21
- scope :active, -> { where(status: "open", out_of_date: false) }
22
- scope :archived, -> { where("status != 'open' OR out_of_date = ?", true) }
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: "accepted", resolved_by_user: user)
99
+ update!(status: "todo", resolved_by_user: user)
98
100
  end
99
101
 
100
- def dismiss!(user)
101
- update!(status: "dismissed", resolved_by_user: user)
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
 
@@ -12,7 +12,7 @@ module CoPlan
12
12
  record.plan.created_by_user_id == user.id
13
13
  end
14
14
 
15
- def dismiss?
15
+ def discard?
16
16
  record.plan.created_by_user_id == user.id
17
17
  end
18
18
 
@@ -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></textarea>
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></textarea>
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.status == 'open' ? 'live' : 'abandoned' %>"><%= thread.status %></span>
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.status == "open" %>
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 "Dismiss", dismiss_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
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 %>