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 +4 -4
- data/app/controllers/coplan/comment_threads_controller.rb +1 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +76 -7
- data/app/models/coplan/comment_thread.rb +19 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +1 -0
- data/app/views/coplan/comment_threads/_thread.html.erb +2 -1
- data/lib/coplan/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 129983ca7890cb3869a1c594ebde86720d3ba80e14861b9ad613c3311eec30b0
|
|
4
|
+
data.tar.gz: 93ee7fa05f6f3676bc414a9f9794a3a04c80cee7181efcab89d484816f72cd79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
|
data/lib/coplan/version.rb
CHANGED