coplan-engine 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7978a493f24f0424cd5a4170890ff0e41e27dc10e9b7dfff2de3e456df2a5c56
4
- data.tar.gz: 541671d9b00b2d10ddae7269c78074db1eb025a75b94d05654a529a1809668c7
3
+ metadata.gz: 129983ca7890cb3869a1c594ebde86720d3ba80e14861b9ad613c3311eec30b0
4
+ data.tar.gz: 93ee7fa05f6f3676bc414a9f9794a3a04c80cee7181efcab89d484816f72cd79
5
5
  SHA512:
6
- metadata.gz: 5b670e1689fc107975f40915ae5cd3b9ee509863a7b3f690b399710d3220308834d5043af9e1dec61f993bbc5759e134f5364aa43c9b3e66503612fb9d7138d0
7
- data.tar.gz: 8cf385dcdbf1309395113203c186f7813e0ec51518e49ea0f7a3120075ce7b4b07453555cfd576db3c279fe415e7157e7cc756407d6297467018ffd52d199cae
6
+ metadata.gz: 291848526406f7b918dd2cf86e161ccec3e85eecf73277325af84fb320ea78b4fd64c260c0f539ba6bf0e59b747906c2d173fdf39c1920c72522537ae62fe0ae
7
+ data.tar.gz: 8f5af631ce15ede7498305751f51a89dfc71136448196a959e4aa3223e5c00fde1955645aefa507db117f72269d8ab256d7bad1053c56cb34a218b509e9cd6e6
@@ -12,6 +12,7 @@ module CoPlan
12
12
  plan_version: @plan.current_plan_version,
13
13
  anchor_text: params[:comment_thread][:anchor_text].presence,
14
14
  anchor_context: params[:comment_thread][:anchor_context].presence,
15
+ anchor_occurrence: params[:comment_thread][:anchor_occurrence].presence&.to_i,
15
16
  start_line: params[:comment_thread][:start_line].presence,
16
17
  end_line: params[:comment_thread][:end_line].presence,
17
18
  created_by_user: current_user
@@ -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"]
5
5
  static values = { planId: String }
6
6
 
7
7
  connect() {
@@ -48,6 +48,7 @@ export default class extends Controller {
48
48
 
49
49
  this.selectedText = text
50
50
  this.selectedContext = this.extractContext(range, text)
51
+ this.selectedOccurrence = this.computeOccurrence(range, text)
51
52
 
52
53
  // Position popover near the selection
53
54
  const rect = range.getBoundingClientRect()
@@ -62,9 +63,10 @@ export default class extends Controller {
62
63
  event.preventDefault()
63
64
  if (!this.selectedText) return
64
65
 
65
- // Set the anchor text and surrounding context
66
+ // Set the anchor text, surrounding context, and occurrence index
66
67
  this.anchorInputTarget.value = this.selectedText
67
68
  this.contextInputTarget.value = this.selectedContext || ""
69
+ this.occurrenceInputTarget.value = this.selectedOccurrence != null ? this.selectedOccurrence : ""
68
70
  this.anchorQuoteTarget.textContent = this.selectedText.length > 120
69
71
  ? this.selectedText.substring(0, 120) + "…"
70
72
  : this.selectedText
@@ -114,11 +116,13 @@ export default class extends Controller {
114
116
  this.formTarget.style.display = "none"
115
117
  this.anchorInputTarget.value = ""
116
118
  this.contextInputTarget.value = ""
119
+ this.occurrenceInputTarget.value = ""
117
120
  this.anchorPreviewTarget.style.display = "none"
118
121
  const textarea = this.formTarget.querySelector("textarea")
119
122
  if (textarea) textarea.value = ""
120
123
  this.selectedText = null
121
124
  this.selectedContext = null
125
+ this.selectedOccurrence = null
122
126
  }
123
127
 
124
128
  scrollToAnchor(event) {
@@ -126,16 +130,18 @@ export default class extends Controller {
126
130
  if (!anchor) return
127
131
 
128
132
  const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
133
+ const occurrence = event.currentTarget.dataset.anchorOccurrence
129
134
 
130
135
  // Remove existing highlights first
131
136
  this.contentTarget.querySelectorAll(".anchor-highlight--active").forEach(el => {
132
137
  el.classList.remove("anchor-highlight--active")
133
138
  })
134
139
 
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")
140
+ // Build full text for position lookups
141
+ this.fullText = this.contentTarget.textContent
142
+
143
+ // Find and highlight using occurrence index (most reliable), then context, then first match
144
+ const highlighted = this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight--active", null)
139
145
  if (highlighted) {
140
146
  highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
141
147
  }
@@ -166,6 +172,21 @@ export default class extends Controller {
166
172
  return fullText.slice(start, end)
167
173
  }
168
174
 
175
+ // Computes the 1-based occurrence number of the selected text in the DOM content.
176
+ // This is sent to the server so resolve_anchor_position picks the right match.
177
+ computeOccurrence(range, text) {
178
+ const offset = this.getSelectionOffset(range)
179
+ const fullText = this.contentTarget.textContent
180
+
181
+ let count = 0
182
+ let pos = -1
183
+ while ((pos = fullText.indexOf(text, pos + 1)) !== -1) {
184
+ count++
185
+ if (pos >= offset) return count
186
+ }
187
+ return count > 0 ? count : 1
188
+ }
189
+
169
190
  getSelectionOffset(range) {
170
191
  if (!range || !this.contentTarget) return 0
171
192
 
@@ -200,8 +221,9 @@ export default class extends Controller {
200
221
  threads.forEach(thread => {
201
222
  const anchor = thread.dataset.anchorText
202
223
  const context = thread.dataset.anchorContext
224
+ const occurrence = thread.dataset.anchorOccurrence
203
225
  if (anchor && anchor.length > 0) {
204
- this.findAndHighlightWithContext(anchor, context, "anchor-highlight")
226
+ this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight", thread)
205
227
  }
206
228
  })
207
229
 
@@ -283,6 +305,53 @@ export default class extends Controller {
283
305
  })
284
306
  }
285
307
 
308
+ // Primary highlight method: uses occurrence index (from server-side OT positions)
309
+ // to find the correct Nth occurrence of anchor text in the rendered DOM.
310
+ // Falls back to context matching, then to first occurrence.
311
+ findAndHighlightForThread(text, context, occurrence, className, threadEl) {
312
+ const fullText = this.fullText
313
+ let targetIndex = -1
314
+
315
+ // Strategy 1: Use the occurrence index from the server (most reliable)
316
+ if (occurrence !== undefined && occurrence !== "") {
317
+ const occurrenceNum = parseInt(occurrence, 10)
318
+ if (!isNaN(occurrenceNum)) {
319
+ targetIndex = this.findNthOccurrence(fullText, text, occurrenceNum)
320
+ }
321
+ }
322
+
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
+ if (targetIndex === -1) return null
340
+
341
+ const mark = this.highlightAtIndex(targetIndex, text.length, className)
342
+ if (mark && threadEl) threadEl._highlightMark = mark
343
+ return mark
344
+ }
345
+
346
+ findNthOccurrence(text, search, n) {
347
+ let pos = -1
348
+ for (let i = 0; i <= n; i++) {
349
+ pos = text.indexOf(search, pos + 1)
350
+ if (pos === -1) return -1
351
+ }
352
+ return pos
353
+ }
354
+
286
355
  findAndHighlightWithContext(text, context, className) {
287
356
  // Use context to find the right occurrence of the anchor text
288
357
  const fullText = this.fullText
@@ -106,6 +106,25 @@ module CoPlan
106
106
  !out_of_date
107
107
  end
108
108
 
109
+ # Returns the 0-based occurrence index of anchor_text in the raw markdown,
110
+ # computed from anchor_start. The frontend uses this to find the correct
111
+ # occurrence in the rendered DOM text instead of relying on context matching.
112
+ def anchor_occurrence_index
113
+ return nil unless anchored? && anchor_start.present?
114
+
115
+ content = plan.current_content
116
+ return nil unless content.present?
117
+
118
+ count = 0
119
+ pos = 0
120
+ while (idx = content.index(anchor_text, pos))
121
+ break if idx >= anchor_start
122
+ count += 1
123
+ pos = idx + 1
124
+ end
125
+ count
126
+ end
127
+
109
128
  def anchor_context_with_highlight(chars: 100)
110
129
  return nil unless anchored? && anchor_start.present?
111
130
 
@@ -2,6 +2,7 @@
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>
@@ -1,6 +1,7 @@
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">
@@ -10,7 +11,7 @@
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
 
@@ -1,3 +1,3 @@
1
1
  module CoPlan
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coplan-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Block