coplan-engine 0.4.0 → 1.0.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,252 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const STORAGE_KEY = "coplan:content-nav-visible"
4
+
5
+ export default class extends Controller {
6
+ static targets = ["sidebar", "list", "content", "toggleBtn", "showBtn"]
7
+ static values = { visible: { type: Boolean, default: true } }
8
+
9
+ connect() {
10
+ const stored = localStorage.getItem(STORAGE_KEY)
11
+ if (stored !== null) {
12
+ this.visibleValue = stored === "true"
13
+ }
14
+
15
+ this.buildToc()
16
+ this.setupScrollTracking()
17
+
18
+ this.handleKeydown = this.handleKeydown.bind(this)
19
+ document.addEventListener("keydown", this.handleKeydown)
20
+
21
+ this._handleAnchorsUpdated = () => this.updateCommentBadges()
22
+ this.element.addEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
23
+ }
24
+
25
+ disconnect() {
26
+ if (this._scrollHandler) {
27
+ window.removeEventListener("scroll", this._scrollHandler)
28
+ }
29
+ document.removeEventListener("keydown", this.handleKeydown)
30
+ if (this._handleAnchorsUpdated) {
31
+ this.element.removeEventListener("coplan:anchors-updated", this._handleAnchorsUpdated)
32
+ }
33
+ }
34
+
35
+ buildToc() {
36
+ const rendered = this.contentTarget.querySelector(".markdown-rendered")
37
+ if (!rendered) return
38
+
39
+ this.listTarget.innerHTML = ""
40
+ this._itemsById = new Map()
41
+ this._headings = Array.from(rendered.querySelectorAll("h1, h2, h3"))
42
+
43
+ if (this._headings.length === 0) {
44
+ this.sidebarTarget.style.display = "none"
45
+ if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "none"
46
+ return
47
+ }
48
+ this.sidebarTarget.style.display = ""
49
+ if (this.hasShowBtnTarget) this.showBtnTarget.style.display = ""
50
+
51
+ const usedIds = new Set()
52
+
53
+ this._headings.forEach((heading, index) => {
54
+ let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}`
55
+ let id = baseId
56
+ let suffix = 2
57
+ while (usedIds.has(id)) {
58
+ id = `${baseId}-${suffix++}`
59
+ }
60
+ heading.id = id
61
+ usedIds.add(id)
62
+
63
+ const li = document.createElement("li")
64
+ li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}`
65
+ li.dataset.headingId = id
66
+
67
+ const a = document.createElement("a")
68
+ a.className = "content-nav__link"
69
+ a.href = `#${id}`
70
+ a.addEventListener("click", (e) => this.handleLinkClick(e, heading))
71
+
72
+ const text = document.createElement("span")
73
+ text.className = "content-nav__link-text"
74
+ text.textContent = heading.textContent
75
+ a.appendChild(text)
76
+
77
+ li.appendChild(a)
78
+ this.listTarget.appendChild(li)
79
+ this._itemsById.set(id, li)
80
+ })
81
+
82
+ this.updateCommentBadges()
83
+ }
84
+
85
+ slugify(text) {
86
+ return text
87
+ .toLowerCase()
88
+ .replace(/\s+/g, "-")
89
+ .replace(/[^a-z0-9-]/g, "")
90
+ .replace(/-{2,}/g, "-")
91
+ .replace(/^-|-$/g, "")
92
+ }
93
+
94
+ setupScrollTracking() {
95
+ if (!this._headings || this._headings.length === 0) return
96
+
97
+ this._scrollHandler = () => {
98
+ if (this._ignoreScroll) {
99
+ clearTimeout(this._scrollEndTimer)
100
+ this._scrollEndTimer = setTimeout(() => {
101
+ this._ignoreScroll = false
102
+ }, 100)
103
+ return
104
+ }
105
+ if (!this._scrollTicking) {
106
+ requestAnimationFrame(() => {
107
+ this._updateActiveFromScroll()
108
+ this._scrollTicking = false
109
+ })
110
+ this._scrollTicking = true
111
+ }
112
+ }
113
+ window.addEventListener("scroll", this._scrollHandler, { passive: true })
114
+ this._updateActiveFromScroll()
115
+ }
116
+
117
+ _updateActiveFromScroll() {
118
+ const threshold = 100
119
+ let active = null
120
+ for (const heading of this._headings) {
121
+ if (heading.getBoundingClientRect().top <= threshold) {
122
+ active = heading
123
+ } else {
124
+ break
125
+ }
126
+ }
127
+
128
+ const id = active?.id || this._headings[0]?.id
129
+ if (id && id !== this._activeHeadingId) {
130
+ this._activeHeadingId = id
131
+ this._setActiveLink(id)
132
+ }
133
+ }
134
+
135
+ handleLinkClick(event, heading) {
136
+ event.preventDefault()
137
+ history.replaceState(null, "", `#${heading.id}`)
138
+
139
+ this._ignoreScroll = true
140
+ this._activeHeadingId = heading.id
141
+ this._setActiveLink(heading.id)
142
+
143
+ heading.scrollIntoView({ behavior: "smooth", block: "start" })
144
+ }
145
+
146
+ _setActiveLink(id) {
147
+ this.listTarget.querySelectorAll(".content-nav__link").forEach(link => {
148
+ link.classList.remove("content-nav__link--active")
149
+ })
150
+
151
+ const item = this._itemsById?.get(id)
152
+ const link = item?.querySelector(".content-nav__link")
153
+ if (link) {
154
+ link.classList.add("content-nav__link--active")
155
+ link.scrollIntoView({ block: "nearest" })
156
+ }
157
+ }
158
+
159
+ toggle() {
160
+ this.visibleValue = !this.visibleValue
161
+ localStorage.setItem(STORAGE_KEY, this.visibleValue)
162
+ }
163
+
164
+ visibleValueChanged() {
165
+ if (!this.hasSidebarTarget) return
166
+
167
+ const hidden = !this.visibleValue
168
+ this.sidebarTarget.classList.toggle("content-nav--hidden", hidden)
169
+ this.sidebarTarget.setAttribute("aria-hidden", String(hidden))
170
+ if (hidden) {
171
+ this.sidebarTarget.setAttribute("inert", "")
172
+ } else {
173
+ this.sidebarTarget.removeAttribute("inert")
174
+ }
175
+
176
+ if (this.hasToggleBtnTarget) {
177
+ this.toggleBtnTarget.setAttribute("aria-expanded", String(this.visibleValue))
178
+ }
179
+ }
180
+
181
+ handleKeydown(event) {
182
+ const tag = event.target.tagName
183
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
184
+ if (event.metaKey || event.ctrlKey || event.altKey) return
185
+
186
+ if (event.key === "]") {
187
+ event.preventDefault()
188
+ this.toggle()
189
+ }
190
+ }
191
+
192
+ updateCommentBadges() {
193
+ if (!this._headings || this._headings.length === 0) return
194
+
195
+ const rendered = this.contentTarget.querySelector(".markdown-rendered")
196
+ if (!rendered) return
197
+
198
+ this._headings.forEach((heading, index) => {
199
+ const nextHeading = this._headings[index + 1]
200
+ const threads = this.collectThreadsBetween(heading, nextHeading, rendered)
201
+
202
+ let pendingCount = 0
203
+ let todoCount = 0
204
+ threads.forEach(status => {
205
+ if (status === "pending") pendingCount++
206
+ else if (status === "todo") todoCount++
207
+ })
208
+
209
+ const item = this._itemsById?.get(heading.id)
210
+ if (!item) return
211
+
212
+ const existing = item.querySelector(".content-nav__badge")
213
+ if (existing) existing.remove()
214
+
215
+ const total = pendingCount + todoCount
216
+ if (total === 0) return
217
+
218
+ const badge = document.createElement("span")
219
+ const badgeType = pendingCount > 0 ? "pending" : "todo"
220
+ badge.className = `content-nav__badge content-nav__badge--${badgeType}`
221
+ badge.textContent = total
222
+ item.querySelector(".content-nav__link").appendChild(badge)
223
+ })
224
+ }
225
+
226
+ collectThreadsBetween(startHeading, endHeading, container) {
227
+ const seen = new Set()
228
+ const threads = []
229
+ let collecting = false
230
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, null)
231
+
232
+ let node
233
+ while ((node = walker.nextNode())) {
234
+ if (node === startHeading) {
235
+ collecting = true
236
+ continue
237
+ }
238
+ if (endHeading && node === endHeading) break
239
+
240
+ if (collecting && node.tagName === "MARK" && node.classList.contains("anchor-highlight--open")) {
241
+ const threadId = node.dataset.threadId
242
+ if (threadId && !seen.has(threadId)) {
243
+ seen.add(threadId)
244
+ const status = node.classList.contains("anchor-highlight--pending") ? "pending" : "todo"
245
+ threads.push(status)
246
+ }
247
+ }
248
+ }
249
+
250
+ return threads
251
+ }
252
+ }
@@ -0,0 +1,44 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { createConsumer } from "@rails/actioncable"
3
+
4
+ let sharedConsumer
5
+
6
+ function getConsumer() {
7
+ if (!sharedConsumer) sharedConsumer = createConsumer()
8
+ return sharedConsumer
9
+ }
10
+
11
+ export default class extends Controller {
12
+ static values = { planId: String }
13
+
14
+ connect() {
15
+ this.channel = getConsumer().subscriptions.create(
16
+ { channel: "CoPlan::PlanPresenceChannel", plan_id: this.planIdValue },
17
+ {
18
+ connected: () => { this.startPinging() },
19
+ disconnected: () => { this.stopPinging() }
20
+ }
21
+ )
22
+ }
23
+
24
+ disconnect() {
25
+ this.stopPinging()
26
+ if (this.channel) {
27
+ this.channel.unsubscribe()
28
+ this.channel = null
29
+ }
30
+ }
31
+
32
+ startPinging() {
33
+ this.pingInterval = setInterval(() => {
34
+ if (this.channel) this.channel.perform("ping")
35
+ }, 15000)
36
+ }
37
+
38
+ stopPinging() {
39
+ if (this.pingInterval) {
40
+ clearInterval(this.pingInterval)
41
+ this.pingInterval = null
42
+ }
43
+ }
44
+ }
@@ -8,9 +8,11 @@ export default class extends Controller {
8
8
  this.selectedText = null
9
9
  this._activeMark = null
10
10
  this._activePopover = null
11
- this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
12
- document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
11
+ this._boundHandleMouseUp = this.handleMouseUp.bind(this)
12
+ this._boundHandleDocumentMouseDown = this.handleDocumentMouseDown.bind(this)
13
13
  this._handleScroll = this._handleScroll.bind(this)
14
+ this.contentTarget.addEventListener("mouseup", this._boundHandleMouseUp)
15
+ document.addEventListener("mousedown", this._boundHandleDocumentMouseDown)
14
16
  window.addEventListener("scroll", this._handleScroll, { passive: true })
15
17
  this.highlightAnchors()
16
18
 
@@ -22,8 +24,8 @@ export default class extends Controller {
22
24
  }
23
25
 
24
26
  disconnect() {
25
- this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
26
- document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
27
+ this.contentTarget.removeEventListener("mouseup", this._boundHandleMouseUp)
28
+ document.removeEventListener("mousedown", this._boundHandleDocumentMouseDown)
27
29
  window.removeEventListener("scroll", this._handleScroll)
28
30
  if (this._threadsObserver) {
29
31
  this._threadsObserver.disconnect()
@@ -36,6 +38,20 @@ export default class extends Controller {
36
38
  setTimeout(() => this.checkSelection(event), 10)
37
39
  }
38
40
 
41
+ dismiss(event) {
42
+ // Close the comment form if it's visible
43
+ if (this.hasFormTarget && this.formTarget.style.display === "block") {
44
+ event.preventDefault()
45
+ this.hideAndResetForm()
46
+ return
47
+ }
48
+ // Close the selection popover if it's visible
49
+ if (this.hasPopoverTarget && this.popoverTarget.style.display === "block") {
50
+ event.preventDefault()
51
+ this.popoverTarget.style.display = "none"
52
+ }
53
+ }
54
+
39
55
  handleDocumentMouseDown(event) {
40
56
  // Hide popover if clicking outside it
41
57
  if (this.hasPopoverTarget && !this.popoverTarget.contains(event.target)) {
@@ -67,8 +83,11 @@ export default class extends Controller {
67
83
  if (clampTarget) range.setEndAfter(clampTarget)
68
84
  }
69
85
 
70
- // Extract text after clamping so it only contains content-area text
71
- const text = selection.toString().trim()
86
+ // Extract text after clamping so it only contains content-area text.
87
+ // Normalize tabs to spaces — browser selections across table cells
88
+ // produce tab-separated text, but the server matches against
89
+ // space-separated plain text extracted from the markdown AST.
90
+ const text = selection.toString().replace(/\t/g, " ").trim()
72
91
 
73
92
  if (text.length < 3) {
74
93
  this.popoverTarget.style.display = "none"
@@ -251,15 +270,22 @@ export default class extends Controller {
251
270
 
252
271
  // Computes the 1-based occurrence number of the selected text in the DOM content.
253
272
  // This is sent to the server so resolve_anchor_position picks the right match.
273
+ // Uses whitespace-normalized matching for consistency with findAndHighlight.
254
274
  computeOccurrence(range, text) {
255
275
  const offset = this.getSelectionOffset(range)
256
276
  const fullText = this.contentTarget.textContent
277
+ const { normText, origIndices } = this._buildNormalizedMap(fullText)
278
+ const normSearch = this._normalizeWhitespace(text)
279
+
280
+ // Map the DOM offset to the normalized string offset
281
+ let normOffset = origIndices.findIndex(orig => orig >= offset)
282
+ if (normOffset === -1) normOffset = normText.length
257
283
 
258
284
  let count = 0
259
285
  let pos = -1
260
- while ((pos = fullText.indexOf(text, pos + 1)) !== -1) {
286
+ while ((pos = normText.indexOf(normSearch, pos + 1)) !== -1) {
261
287
  count++
262
- if (pos >= offset) return count
288
+ if (pos >= normOffset) return count
263
289
  }
264
290
  return count > 0 ? count : 1
265
291
  }
@@ -311,21 +337,29 @@ export default class extends Controller {
311
337
  const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
312
338
  const specificClass = isOpen ? `anchor-highlight--${status}` : ""
313
339
  const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
314
- const mark = this.findAndHighlight(anchor, occurrence, classes)
315
-
316
- if (mark && threadId) {
317
- // Make highlight clickable to open popover
318
- mark.dataset.threadId = threadId
319
- mark.style.cursor = "pointer"
320
- mark.addEventListener("click", (e) => this.openThreadPopover(e))
321
-
322
- // Create margin dot
340
+ const marks = this.findAndHighlightAll(anchor, occurrence, classes)
341
+
342
+ if (marks.length > 0 && threadId) {
343
+ // Make all highlight marks clickable to open popover.
344
+ // If a mark is shared with another thread, keep the first
345
+ // thread's binding — the margin dot still provides access.
346
+ marks.forEach(mark => {
347
+ if (!mark.dataset.threadId) {
348
+ mark.dataset.threadId = threadId
349
+ mark.style.cursor = "pointer"
350
+ mark.addEventListener("click", (e) => this.openThreadPopover(e))
351
+ }
352
+ })
353
+
354
+ // Create margin dot aligned to the first mark
323
355
  if (this.hasMarginTarget) {
324
- this.createMarginDot(mark, threadId, status)
356
+ this.createMarginDot(marks[0], threadId, status)
325
357
  }
326
358
  }
327
359
  }
328
360
  })
361
+
362
+ this.element.dispatchEvent(new CustomEvent("coplan:anchors-updated", { bubbles: true }))
329
363
  }
330
364
 
331
365
  createMarginDot(highlightMark, threadId, status) {
@@ -347,33 +381,94 @@ export default class extends Controller {
347
381
 
348
382
  // Find and highlight the Nth occurrence of text in the rendered DOM.
349
383
  // Uses the occurrence index from server-side positional data.
384
+ // Performs whitespace-normalized matching so that anchor text captured
385
+ // from browser selections (which may differ in whitespace from the DOM
386
+ // textContent, e.g. tabs in table selections) can still be located.
350
387
  findAndHighlight(text, occurrence, className) {
351
388
  const fullText = this.fullText
352
- let targetIndex = -1
353
389
 
354
- if (occurrence !== undefined && occurrence !== "") {
355
- const occurrenceNum = parseInt(occurrence, 10)
356
- if (!isNaN(occurrenceNum)) {
357
- targetIndex = this.findNthOccurrence(fullText, text, occurrenceNum)
390
+ if (occurrence === undefined || occurrence === "") return null
391
+
392
+ const occurrenceNum = parseInt(occurrence, 10)
393
+ if (isNaN(occurrenceNum)) return null
394
+
395
+ const match = this._findNthNormalized(fullText, text, occurrenceNum)
396
+ if (!match) return null
397
+
398
+ return this.highlightAtIndex(match.startIndex, match.matchLength, className)
399
+ }
400
+
401
+ // Like findAndHighlight but returns all created/reused marks (for multi-cell spans).
402
+ findAndHighlightAll(text, occurrence, className) {
403
+ const fullText = this.fullText
404
+
405
+ if (occurrence === undefined || occurrence === "") return []
406
+
407
+ const occurrenceNum = parseInt(occurrence, 10)
408
+ if (isNaN(occurrenceNum)) return []
409
+
410
+ const match = this._findNthNormalized(fullText, text, occurrenceNum)
411
+ if (!match) return []
412
+
413
+ return this.highlightAtIndexAll(match.startIndex, match.matchLength, className)
414
+ }
415
+
416
+ // Collapses runs of whitespace (spaces, tabs, newlines) into single spaces.
417
+ _normalizeWhitespace(str) {
418
+ return str.replace(/\s+/g, " ")
419
+ }
420
+
421
+ // Builds a whitespace-normalized version of `text` with a parallel array
422
+ // mapping each normalized position back to its original index.
423
+ // Returns { normText, origIndices } where origIndices[i] is the original
424
+ // index of the character at normalized position i.
425
+ _buildNormalizedMap(text) {
426
+ let normText = ""
427
+ const origIndices = []
428
+ let inWhitespace = false
429
+
430
+ for (let i = 0; i < text.length; i++) {
431
+ if (/\s/.test(text[i])) {
432
+ if (!inWhitespace) {
433
+ normText += " "
434
+ origIndices.push(i)
435
+ inWhitespace = true
436
+ }
437
+ } else {
438
+ normText += text[i]
439
+ origIndices.push(i)
440
+ inWhitespace = false
358
441
  }
359
442
  }
360
443
 
361
- if (targetIndex === -1) return null
362
-
363
- return this.highlightAtIndex(targetIndex, text.length, className)
444
+ return { normText, origIndices }
364
445
  }
365
446
 
366
- findNthOccurrence(text, search, n) {
447
+ // Finds the Nth occurrence of `search` in `text` using whitespace-normalized
448
+ // matching. Returns { startIndex, matchLength } in the *original* text,
449
+ // or null if not found.
450
+ _findNthNormalized(text, search, n) {
451
+ const { normText, origIndices } = this._buildNormalizedMap(text)
452
+ const normSearch = this._normalizeWhitespace(search)
453
+
367
454
  let pos = -1
368
455
  for (let i = 0; i <= n; i++) {
369
- pos = text.indexOf(search, pos + 1)
370
- if (pos === -1) return -1
456
+ pos = normText.indexOf(normSearch, pos + 1)
457
+ if (pos === -1) return null
371
458
  }
372
- return pos
459
+
460
+ const origStart = origIndices[pos]
461
+ const origEnd = origIndices[pos + normSearch.length - 1] + 1
462
+ return { startIndex: origStart, matchLength: origEnd - origStart }
373
463
  }
374
464
 
375
465
  highlightAtIndex(startIndex, length, className) {
376
- if (startIndex < 0 || length <= 0) return null
466
+ const marks = this.highlightAtIndexAll(startIndex, length, className)
467
+ return marks[0] || null
468
+ }
469
+
470
+ highlightAtIndexAll(startIndex, length, className) {
471
+ if (startIndex < 0 || length <= 0) return []
377
472
 
378
473
  const walker = document.createTreeWalker(
379
474
  this.contentTarget,
@@ -391,7 +486,7 @@ export default class extends Controller {
391
486
  }
392
487
 
393
488
  const matchEnd = startIndex + length
394
- let firstHighlighted = null
489
+ const marks = []
395
490
 
396
491
  for (let i = 0; i < textNodes.length; i++) {
397
492
  const tn = textNodes[i]
@@ -400,9 +495,25 @@ export default class extends Controller {
400
495
  if (nodeEnd <= startIndex) continue
401
496
  if (tn.start >= matchEnd) break
402
497
 
498
+ // Skip structural whitespace text nodes inside table elements —
499
+ // wrapping these in <mark> produces invalid HTML and breaks table layout.
500
+ const parentTag = tn.node.parentElement?.tagName
501
+ if (parentTag && /^(TABLE|THEAD|TBODY|TFOOT|TR)$/.test(parentTag)) continue
502
+
403
503
  const localStart = Math.max(0, startIndex - tn.start)
404
504
  const localEnd = Math.min(tn.node.textContent.length, matchEnd - tn.start)
405
505
 
506
+ // Skip zero-length ranges (e.g. from text node splits by prior highlights)
507
+ if (localEnd <= localStart) continue
508
+
509
+ // If the text is already inside a highlight mark (from another thread
510
+ // anchored to the same text), reuse that mark instead of nesting.
511
+ const existingMark = tn.node.parentElement?.closest("mark.anchor-highlight")
512
+ if (existingMark) {
513
+ if (!marks.includes(existingMark)) marks.push(existingMark)
514
+ continue
515
+ }
516
+
406
517
  const range = document.createRange()
407
518
  range.setStart(tn.node, localStart)
408
519
  range.setEnd(tn.node, localEnd)
@@ -411,9 +522,9 @@ export default class extends Controller {
411
522
  mark.className = className
412
523
  range.surroundContents(mark)
413
524
 
414
- if (!firstHighlighted) firstHighlighted = mark
525
+ marks.push(mark)
415
526
  }
416
527
 
417
- return firstHighlighted
528
+ return marks
418
529
  }
419
530
  }
@@ -11,6 +11,10 @@ module CoPlan
11
11
 
12
12
  after_create_commit :notify_plan_author, if: :first_comment_in_thread?
13
13
 
14
+ def agent?
15
+ agent_name.present? || author_type.in?(%w[local_agent cloud_persona])
16
+ end
17
+
14
18
  private
15
19
 
16
20
  def first_comment_in_thread?
@@ -112,23 +112,25 @@ module CoPlan
112
112
  !out_of_date
113
113
  end
114
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.
115
+ # Returns the 0-based occurrence index of anchor_text in the rendered
116
+ # (stripped) content, computed from anchor_start. The frontend uses this
117
+ # to find the correct occurrence in the rendered DOM text.
118
118
  def anchor_occurrence_index
119
119
  return nil unless anchored? && anchor_start.present?
120
120
 
121
121
  content = plan.current_content
122
122
  return nil unless content.present?
123
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
124
+ stripped, pos_map = self.class.strip_markdown(content)
125
+ # Map raw anchor_start to its position in the stripped string.
126
+ # Use >= to find the closest valid position if anchor_start falls
127
+ # on a stripped formatting character.
128
+ stripped_start = pos_map.index { |raw_idx| raw_idx >= anchor_start }
129
+ return nil if stripped_start.nil?
130
+
131
+ normalized_anchor = anchor_text.gsub("\t", " ")
132
+ ranges = find_all_occurrences(stripped, normalized_anchor)
133
+ ranges.index { |s, _| s >= stripped_start } || 0
132
134
  end
133
135
 
134
136
  def anchor_context_with_highlight(chars: 100)
@@ -147,6 +149,10 @@ module CoPlan
147
149
  "#{before}**#{anchor}**#{after}"
148
150
  end
149
151
 
152
+ def self.strip_markdown(content)
153
+ Plans::MarkdownTextExtractor.call(content)
154
+ end
155
+
150
156
  private
151
157
 
152
158
  def resolve_anchor_position
@@ -158,11 +164,26 @@ module CoPlan
158
164
  occurrence = self.anchor_occurrence || 1
159
165
  return if occurrence < 1
160
166
 
161
- ranges = []
162
- start_pos = 0
163
- while (idx = content.index(anchor_text, start_pos))
164
- ranges << [idx, idx + anchor_text.length]
165
- start_pos = idx + anchor_text.length
167
+ # First, try an exact match against the raw markdown.
168
+ ranges = find_all_occurrences(content, anchor_text)
169
+
170
+ # If no exact match, the selected text may span markdown formatting
171
+ # (e.g. DOM text "Hello me you" vs raw "Hello `me` you", or table
172
+ # cell text without pipe delimiters). Parse the markdown AST to
173
+ # extract plain text with source position mapping.
174
+ if ranges.empty?
175
+ stripped, pos_map = self.class.strip_markdown(content)
176
+ # Normalize tabs to spaces — browser selections across table cells
177
+ # produce tab-separated text, but the stripped markdown uses spaces.
178
+ normalized_anchor = anchor_text.gsub("\t", " ")
179
+ stripped_ranges = find_all_occurrences(stripped, normalized_anchor)
180
+
181
+ ranges = stripped_ranges.map do |s, e|
182
+ raw_start = first_real_pos(pos_map, s, :forward)
183
+ raw_end = first_real_pos(pos_map, e - 1, :backward)
184
+ next nil unless raw_start && raw_end
185
+ [raw_start, raw_end + 1]
186
+ end.compact
166
187
  end
167
188
 
168
189
  if ranges.length >= occurrence
@@ -172,5 +193,26 @@ module CoPlan
172
193
  self.anchor_revision = plan.current_revision
173
194
  end
174
195
  end
196
+
197
+ # Finds the nearest non-sentinel (-1) position in the pos_map,
198
+ # scanning forward or backward from the given index.
199
+ def first_real_pos(pos_map, idx, direction)
200
+ step = direction == :forward ? 1 : -1
201
+ while idx >= 0 && idx < pos_map.length
202
+ return pos_map[idx] if pos_map[idx] >= 0
203
+ idx += step
204
+ end
205
+ nil
206
+ end
207
+
208
+ def find_all_occurrences(text, search)
209
+ ranges = []
210
+ start_pos = 0
211
+ while (idx = text.index(search, start_pos))
212
+ ranges << [idx, idx + search.length]
213
+ start_pos = idx + search.length
214
+ end
215
+ ranges
216
+ end
175
217
  end
176
218
  end