coplan-engine 0.2.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 +350 -7
- 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 +41 -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 +57 -0
- data/app/helpers/coplan/comments_helper.rb +4 -3
- data/app/helpers/coplan/markdown_helper.rb +7 -1
- data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
- 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 +203 -56
- 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/apply_operations.rb +43 -0
- data/app/services/coplan/plans/commit_session.rb +26 -1
- data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
- data/app/services/coplan/plans/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
- 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/app/views/layouts/coplan/application.html.erb +2 -0
- data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
- data/lib/coplan/version.rb +1 -1
- metadata +8 -1
|
@@ -6,8 +6,14 @@ export default class extends Controller {
|
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
this.selectedText = null
|
|
9
|
-
this.
|
|
10
|
-
|
|
9
|
+
this._activeMark = null
|
|
10
|
+
this._activePopover = null
|
|
11
|
+
this._boundHandleMouseUp = this.handleMouseUp.bind(this)
|
|
12
|
+
this._boundHandleDocumentMouseDown = this.handleDocumentMouseDown.bind(this)
|
|
13
|
+
this._handleScroll = this._handleScroll.bind(this)
|
|
14
|
+
this.contentTarget.addEventListener("mouseup", this._boundHandleMouseUp)
|
|
15
|
+
document.addEventListener("mousedown", this._boundHandleDocumentMouseDown)
|
|
16
|
+
window.addEventListener("scroll", this._handleScroll, { passive: true })
|
|
11
17
|
this.highlightAnchors()
|
|
12
18
|
|
|
13
19
|
// Watch for broadcast-appended threads and re-highlight
|
|
@@ -18,8 +24,9 @@ export default class extends Controller {
|
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
disconnect() {
|
|
21
|
-
this.contentTarget.removeEventListener("mouseup", this.
|
|
22
|
-
document.removeEventListener("mousedown", this.
|
|
27
|
+
this.contentTarget.removeEventListener("mouseup", this._boundHandleMouseUp)
|
|
28
|
+
document.removeEventListener("mousedown", this._boundHandleDocumentMouseDown)
|
|
29
|
+
window.removeEventListener("scroll", this._handleScroll)
|
|
23
30
|
if (this._threadsObserver) {
|
|
24
31
|
this._threadsObserver.disconnect()
|
|
25
32
|
this._threadsObserver = null
|
|
@@ -31,6 +38,20 @@ export default class extends Controller {
|
|
|
31
38
|
setTimeout(() => this.checkSelection(event), 10)
|
|
32
39
|
}
|
|
33
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
|
+
|
|
34
55
|
handleDocumentMouseDown(event) {
|
|
35
56
|
// Hide popover if clicking outside it
|
|
36
57
|
if (this.hasPopoverTarget && !this.popoverTarget.contains(event.target)) {
|
|
@@ -40,17 +61,36 @@ export default class extends Controller {
|
|
|
40
61
|
|
|
41
62
|
checkSelection(event) {
|
|
42
63
|
const selection = window.getSelection()
|
|
43
|
-
const text = selection.toString().trim()
|
|
44
64
|
|
|
45
|
-
if (
|
|
46
|
-
|
|
65
|
+
if (!selection.rangeCount) return
|
|
66
|
+
const range = selection.getRangeAt(0)
|
|
67
|
+
|
|
68
|
+
// Make sure at least part of the selection is within the content area.
|
|
69
|
+
// Whole-line selections (e.g. triple-click) can set commonAncestorContainer
|
|
70
|
+
// to a parent element above contentTarget, so we check start/end individually.
|
|
71
|
+
const startInContent = this.contentTarget.contains(range.startContainer)
|
|
72
|
+
const endInContent = this.contentTarget.contains(range.endContainer)
|
|
73
|
+
if (!startInContent) {
|
|
47
74
|
return
|
|
48
75
|
}
|
|
49
76
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
// Clamp the range to the last rendered markdown element, not the
|
|
78
|
+
// content wrapper's lastChild (which is a hidden popover/form control).
|
|
79
|
+
if (startInContent && !endInContent) {
|
|
80
|
+
const clampTarget = this.hasPopoverTarget
|
|
81
|
+
? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
|
|
82
|
+
: this.contentTarget.lastChild
|
|
83
|
+
if (clampTarget) range.setEndAfter(clampTarget)
|
|
84
|
+
}
|
|
85
|
+
|
|
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()
|
|
91
|
+
|
|
92
|
+
if (text.length < 3) {
|
|
93
|
+
this.popoverTarget.style.display = "none"
|
|
54
94
|
return
|
|
55
95
|
}
|
|
56
96
|
|
|
@@ -111,7 +151,10 @@ export default class extends Controller {
|
|
|
111
151
|
if (event.detail.success) {
|
|
112
152
|
const form = event.target
|
|
113
153
|
const textarea = form.querySelector("textarea")
|
|
114
|
-
if (textarea)
|
|
154
|
+
if (textarea) {
|
|
155
|
+
textarea.value = ""
|
|
156
|
+
textarea.blur()
|
|
157
|
+
}
|
|
115
158
|
}
|
|
116
159
|
}
|
|
117
160
|
|
|
@@ -155,40 +198,49 @@ export default class extends Controller {
|
|
|
155
198
|
const popover = document.getElementById(`${threadId}_popover`)
|
|
156
199
|
if (!popover) return
|
|
157
200
|
|
|
158
|
-
// Position the popover near the clicked element
|
|
159
201
|
const trigger = event.currentTarget
|
|
160
|
-
const triggerRect = trigger.getBoundingClientRect()
|
|
161
202
|
|
|
162
|
-
// Hide visually while positioning to prevent flash
|
|
163
203
|
popover.style.visibility = "hidden"
|
|
164
204
|
popover.showPopover()
|
|
205
|
+
this._positionPopoverAtMark(popover, trigger)
|
|
206
|
+
popover.style.visibility = "visible"
|
|
207
|
+
|
|
208
|
+
this._activeMark = trigger
|
|
209
|
+
this._activePopover = popover
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_handleScroll() {
|
|
213
|
+
if (!this._activeMark || !this._activePopover) return
|
|
214
|
+
try {
|
|
215
|
+
if (!this._activePopover.matches(":popover-open")) {
|
|
216
|
+
this._activeMark = null
|
|
217
|
+
this._activePopover = null
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
} catch { return }
|
|
221
|
+
this._positionPopoverAtMark(this._activePopover, this._activeMark)
|
|
222
|
+
}
|
|
165
223
|
|
|
166
|
-
|
|
224
|
+
_positionPopoverAtMark(popover, mark) {
|
|
225
|
+
const markRect = mark.getBoundingClientRect()
|
|
167
226
|
const popoverRect = popover.getBoundingClientRect()
|
|
168
227
|
const viewportWidth = window.innerWidth
|
|
169
228
|
const viewportHeight = window.innerHeight
|
|
170
229
|
|
|
171
|
-
|
|
172
|
-
let
|
|
173
|
-
let left = triggerRect.right + 12
|
|
230
|
+
let top = markRect.top
|
|
231
|
+
let left = markRect.right + 12
|
|
174
232
|
|
|
175
|
-
// If it would overflow right, position to the left
|
|
176
233
|
if (left + popoverRect.width > viewportWidth - 16) {
|
|
177
|
-
left =
|
|
234
|
+
left = markRect.left - popoverRect.width - 12
|
|
178
235
|
}
|
|
179
|
-
|
|
180
|
-
// If it would overflow bottom, shift up
|
|
181
236
|
if (top + popoverRect.height > viewportHeight - 16) {
|
|
182
237
|
top = viewportHeight - popoverRect.height - 16
|
|
183
238
|
}
|
|
184
|
-
|
|
185
|
-
// Ensure it doesn't go outside viewport
|
|
186
239
|
if (top < 16) top = 16
|
|
187
240
|
if (left < 16) left = 16
|
|
188
241
|
|
|
189
242
|
popover.style.top = `${top}px`
|
|
190
243
|
popover.style.left = `${left}px`
|
|
191
|
-
popover.style.visibility = "visible"
|
|
192
244
|
}
|
|
193
245
|
|
|
194
246
|
extractContext(range, selectedText) {
|
|
@@ -218,15 +270,22 @@ export default class extends Controller {
|
|
|
218
270
|
|
|
219
271
|
// Computes the 1-based occurrence number of the selected text in the DOM content.
|
|
220
272
|
// This is sent to the server so resolve_anchor_position picks the right match.
|
|
273
|
+
// Uses whitespace-normalized matching for consistency with findAndHighlight.
|
|
221
274
|
computeOccurrence(range, text) {
|
|
222
275
|
const offset = this.getSelectionOffset(range)
|
|
223
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
|
|
224
283
|
|
|
225
284
|
let count = 0
|
|
226
285
|
let pos = -1
|
|
227
|
-
while ((pos =
|
|
286
|
+
while ((pos = normText.indexOf(normSearch, pos + 1)) !== -1) {
|
|
228
287
|
count++
|
|
229
|
-
if (pos >=
|
|
288
|
+
if (pos >= normOffset) return count
|
|
230
289
|
}
|
|
231
290
|
return count > 0 ? count : 1
|
|
232
291
|
}
|
|
@@ -276,21 +335,31 @@ export default class extends Controller {
|
|
|
276
335
|
if (anchor && anchor.length > 0) {
|
|
277
336
|
const isOpen = status === "pending" || status === "todo"
|
|
278
337
|
const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
mark
|
|
286
|
-
|
|
287
|
-
|
|
338
|
+
const specificClass = isOpen ? `anchor-highlight--${status}` : ""
|
|
339
|
+
const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
|
|
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
|
|
288
355
|
if (this.hasMarginTarget) {
|
|
289
|
-
this.createMarginDot(
|
|
356
|
+
this.createMarginDot(marks[0], threadId, status)
|
|
290
357
|
}
|
|
291
358
|
}
|
|
292
359
|
}
|
|
293
360
|
})
|
|
361
|
+
|
|
362
|
+
this.element.dispatchEvent(new CustomEvent("coplan:anchors-updated", { bubbles: true }))
|
|
294
363
|
}
|
|
295
364
|
|
|
296
365
|
createMarginDot(highlightMark, threadId, status) {
|
|
@@ -300,44 +369,106 @@ export default class extends Controller {
|
|
|
300
369
|
|
|
301
370
|
const dot = document.createElement("button")
|
|
302
371
|
const isOpen = status === "pending" || status === "todo"
|
|
303
|
-
|
|
372
|
+
const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
|
|
373
|
+
dot.className = `margin-dot ${openClass}`
|
|
304
374
|
dot.style.top = `${markRect.top - marginRect.top}px`
|
|
305
375
|
dot.dataset.threadId = threadId
|
|
306
376
|
dot.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
307
|
-
dot.
|
|
377
|
+
dot.setAttribute("aria-label", `${status} comment`)
|
|
308
378
|
|
|
309
379
|
this.marginTarget.appendChild(dot)
|
|
310
380
|
}
|
|
311
381
|
|
|
312
382
|
// Find and highlight the Nth occurrence of text in the rendered DOM.
|
|
313
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.
|
|
314
387
|
findAndHighlight(text, occurrence, className) {
|
|
315
388
|
const fullText = this.fullText
|
|
316
|
-
let targetIndex = -1
|
|
317
389
|
|
|
318
|
-
if (occurrence
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
322
441
|
}
|
|
323
442
|
}
|
|
324
443
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
return this.highlightAtIndex(targetIndex, text.length, className)
|
|
444
|
+
return { normText, origIndices }
|
|
328
445
|
}
|
|
329
446
|
|
|
330
|
-
|
|
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
|
+
|
|
331
454
|
let pos = -1
|
|
332
455
|
for (let i = 0; i <= n; i++) {
|
|
333
|
-
pos =
|
|
334
|
-
if (pos === -1) return
|
|
456
|
+
pos = normText.indexOf(normSearch, pos + 1)
|
|
457
|
+
if (pos === -1) return null
|
|
335
458
|
}
|
|
336
|
-
|
|
459
|
+
|
|
460
|
+
const origStart = origIndices[pos]
|
|
461
|
+
const origEnd = origIndices[pos + normSearch.length - 1] + 1
|
|
462
|
+
return { startIndex: origStart, matchLength: origEnd - origStart }
|
|
337
463
|
}
|
|
338
464
|
|
|
339
465
|
highlightAtIndex(startIndex, length, className) {
|
|
340
|
-
|
|
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 []
|
|
341
472
|
|
|
342
473
|
const walker = document.createTreeWalker(
|
|
343
474
|
this.contentTarget,
|
|
@@ -355,7 +486,7 @@ export default class extends Controller {
|
|
|
355
486
|
}
|
|
356
487
|
|
|
357
488
|
const matchEnd = startIndex + length
|
|
358
|
-
|
|
489
|
+
const marks = []
|
|
359
490
|
|
|
360
491
|
for (let i = 0; i < textNodes.length; i++) {
|
|
361
492
|
const tn = textNodes[i]
|
|
@@ -364,9 +495,25 @@ export default class extends Controller {
|
|
|
364
495
|
if (nodeEnd <= startIndex) continue
|
|
365
496
|
if (tn.start >= matchEnd) break
|
|
366
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
|
+
|
|
367
503
|
const localStart = Math.max(0, startIndex - tn.start)
|
|
368
504
|
const localEnd = Math.min(tn.node.textContent.length, matchEnd - tn.start)
|
|
369
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
|
+
|
|
370
517
|
const range = document.createRange()
|
|
371
518
|
range.setStart(tn.node, localStart)
|
|
372
519
|
range.setEnd(tn.node, localEnd)
|
|
@@ -375,9 +522,9 @@ export default class extends Controller {
|
|
|
375
522
|
mark.className = className
|
|
376
523
|
range.surroundContents(mark)
|
|
377
524
|
|
|
378
|
-
|
|
525
|
+
marks.push(mark)
|
|
379
526
|
}
|
|
380
527
|
|
|
381
|
-
return
|
|
528
|
+
return marks
|
|
382
529
|
}
|
|
383
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
|
data/app/models/coplan/plan.rb
CHANGED
|
@@ -10,6 +10,7 @@ module CoPlan
|
|
|
10
10
|
has_many :comment_threads, dependent: :destroy
|
|
11
11
|
has_many :edit_sessions, dependent: :destroy
|
|
12
12
|
has_one :edit_lease, dependent: :destroy
|
|
13
|
+
has_many :plan_viewers, dependent: :destroy
|
|
13
14
|
|
|
14
15
|
after_initialize { self.tags ||= [] }
|
|
15
16
|
after_initialize { self.metadata ||= {} }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class PlanViewer < ApplicationRecord
|
|
3
|
+
STALE_THRESHOLD = 45.seconds
|
|
4
|
+
|
|
5
|
+
belongs_to :plan
|
|
6
|
+
belongs_to :user, class_name: "CoPlan::User"
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(last_seen_at: STALE_THRESHOLD.ago..) }
|
|
9
|
+
|
|
10
|
+
def self.track(plan:, user:)
|
|
11
|
+
record = find_or_initialize_by(plan: plan, user: user)
|
|
12
|
+
record.update!(last_seen_at: Time.current)
|
|
13
|
+
record
|
|
14
|
+
rescue ActiveRecord::RecordNotUnique
|
|
15
|
+
retry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.expire(plan:, user:)
|
|
19
|
+
where(plan: plan, user: user).update_all(last_seen_at: STALE_THRESHOLD.ago - 1.second)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.active_viewers_for(plan)
|
|
23
|
+
active.where(plan: plan).joins(:user).includes(:user).order("coplan_users.name").map(&:user)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -21,6 +21,8 @@ module CoPlan
|
|
|
21
21
|
apply_insert_under_heading(op, index)
|
|
22
22
|
when "delete_paragraph_containing"
|
|
23
23
|
apply_delete_paragraph_containing(op, index)
|
|
24
|
+
when "replace_section"
|
|
25
|
+
apply_replace_section(op, index)
|
|
24
26
|
else
|
|
25
27
|
raise OperationError, "Operation #{index}: unknown op '#{op["op"]}'"
|
|
26
28
|
end
|
|
@@ -103,6 +105,47 @@ module CoPlan
|
|
|
103
105
|
applied_data
|
|
104
106
|
end
|
|
105
107
|
|
|
108
|
+
def apply_replace_section(op, index)
|
|
109
|
+
heading = op["heading"]
|
|
110
|
+
new_content = op["new_content"]
|
|
111
|
+
|
|
112
|
+
raise OperationError, "Operation #{index}: replace_section requires 'heading'" if heading.blank?
|
|
113
|
+
raise OperationError, "Operation #{index}: replace_section requires 'new_content'" if new_content.nil?
|
|
114
|
+
|
|
115
|
+
range = if op.key?("_pre_resolved_ranges")
|
|
116
|
+
op["_pre_resolved_ranges"][0]
|
|
117
|
+
else
|
|
118
|
+
Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# For body-only replacements (include_heading: false), ensure
|
|
122
|
+
# newlines separate the heading from new content and new content
|
|
123
|
+
# from the next section.
|
|
124
|
+
include_heading = op.fetch("include_heading", true)
|
|
125
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
126
|
+
effective_content = new_content
|
|
127
|
+
if !include_heading && range[0] == range[1]
|
|
128
|
+
# Prepend newline if heading line doesn't end with one
|
|
129
|
+
if range[0] > 0 && @content[range[0] - 1] != "\n"
|
|
130
|
+
effective_content = "\n#{effective_content}"
|
|
131
|
+
end
|
|
132
|
+
# Append newline if next content starts without one
|
|
133
|
+
after = @content[range[1]]
|
|
134
|
+
if after && after != "\n" && !effective_content.end_with?("\n")
|
|
135
|
+
effective_content = "#{effective_content}\n"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@content = @content[0...range[0]] + effective_content + @content[range[1]..]
|
|
140
|
+
|
|
141
|
+
delta = effective_content.length - (range[1] - range[0])
|
|
142
|
+
applied_data = op.except("_pre_resolved_ranges")
|
|
143
|
+
applied_data["resolved_range"] = range
|
|
144
|
+
applied_data["new_range"] = [range[0], range[0] + effective_content.length]
|
|
145
|
+
applied_data["delta"] = delta
|
|
146
|
+
applied_data
|
|
147
|
+
end
|
|
148
|
+
|
|
106
149
|
def apply_delete_paragraph_containing(op, index)
|
|
107
150
|
needle = op["needle"]
|
|
108
151
|
|
|
@@ -65,7 +65,7 @@ module CoPlan
|
|
|
65
65
|
rebased_ops = []
|
|
66
66
|
@session.operations_json.each do |op_data|
|
|
67
67
|
op_data = op_data.transform_keys(&:to_s)
|
|
68
|
-
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
|
|
68
|
+
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count new_content include_heading]
|
|
69
69
|
semantic_op = op_data.slice(*semantic_keys)
|
|
70
70
|
|
|
71
71
|
if op_data["resolved_range"]
|
|
@@ -179,6 +179,31 @@ module CoPlan
|
|
|
179
179
|
|
|
180
180
|
raise SessionConflictError,
|
|
181
181
|
"Paragraph at target position no longer contains '#{op_data["needle"]}'"
|
|
182
|
+
when "replace_section"
|
|
183
|
+
return unless op_data["heading"]
|
|
184
|
+
include_heading = op_data.fetch("include_heading", true)
|
|
185
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
186
|
+
|
|
187
|
+
if include_heading
|
|
188
|
+
first_line_end = content.index("\n", range[0]) || range[1]
|
|
189
|
+
first_line = content[range[0]...[first_line_end, range[1]].min]
|
|
190
|
+
return if first_line&.rstrip == op_data["heading"]&.rstrip
|
|
191
|
+
|
|
192
|
+
raise SessionConflictError,
|
|
193
|
+
"Section heading changed at target position. Expected '#{op_data["heading"]}' " \
|
|
194
|
+
"but found '#{first_line}'"
|
|
195
|
+
else
|
|
196
|
+
search_pos = range[0]
|
|
197
|
+
search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
|
|
198
|
+
heading_line_end = search_pos
|
|
199
|
+
heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
|
|
200
|
+
heading_text = content[heading_line_start...heading_line_end]
|
|
201
|
+
return if heading_text == op_data["heading"]
|
|
202
|
+
|
|
203
|
+
raise SessionConflictError,
|
|
204
|
+
"Section heading before target position changed. Expected '#{op_data["heading"]}' " \
|
|
205
|
+
"but found '#{heading_text}'"
|
|
206
|
+
end
|
|
182
207
|
end
|
|
183
208
|
end
|
|
184
209
|
end
|