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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/coplan/application.css +287 -0
- data/app/channels/coplan/plan_presence_channel.rb +45 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/operations_controller.rb +3 -3
- data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
- data/app/controllers/coplan/plans_controller.rb +1 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
- data/app/helpers/coplan/application_helper.rb +13 -0
- data/app/helpers/coplan/comments_helper.rb +4 -3
- data/app/helpers/coplan/markdown_helper.rb +6 -1
- data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
- data/app/javascript/controllers/coplan/presence_controller.js +44 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +145 -34
- data/app/models/coplan/comment.rb +4 -0
- data/app/models/coplan/comment_thread.rb +58 -16
- data/app/models/coplan/plan.rb +1 -0
- data/app/models/coplan/plan_viewer.rb +26 -0
- data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
- data/app/views/coplan/comments/_comment.html.erb +3 -0
- data/app/views/coplan/plans/_header.html.erb +1 -0
- data/app/views/coplan/plans/_viewers.html.erb +16 -0
- data/app/views/coplan/plans/show.html.erb +25 -3
- data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
- data/lib/coplan/version.rb +1 -1
- metadata +8 -1
|
@@ -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.
|
|
12
|
-
|
|
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.
|
|
26
|
-
document.removeEventListener("mousedown", 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
|
-
|
|
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 =
|
|
286
|
+
while ((pos = normText.indexOf(normSearch, pos + 1)) !== -1) {
|
|
261
287
|
count++
|
|
262
|
-
if (pos >=
|
|
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
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
// Make highlight clickable to open popover
|
|
318
|
-
mark
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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(
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
return this.highlightAtIndex(targetIndex, text.length, className)
|
|
444
|
+
return { normText, origIndices }
|
|
364
445
|
}
|
|
365
446
|
|
|
366
|
-
|
|
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 =
|
|
370
|
-
if (pos === -1) return
|
|
456
|
+
pos = normText.indexOf(normSearch, pos + 1)
|
|
457
|
+
if (pos === -1) return null
|
|
371
458
|
}
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
+
marks.push(mark)
|
|
415
526
|
}
|
|
416
527
|
|
|
417
|
-
return
|
|
528
|
+
return marks
|
|
418
529
|
}
|
|
419
530
|
}
|
|
@@ -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
|
|
116
|
-
# computed from anchor_start. The frontend uses this
|
|
117
|
-
# occurrence in the rendered DOM text
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|