coplan-engine 0.2.0 → 0.4.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 +63 -7
- data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
- data/app/helpers/coplan/application_helper.rb +44 -0
- data/app/helpers/coplan/markdown_helper.rb +1 -0
- data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
- data/app/javascript/controllers/coplan/text_selection_controller.js +61 -25
- 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/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/layouts/coplan/application.html.erb +2 -0
- 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: fc0c571fae079ecc474da55dea08af61a198ca761f6f3c9b5c3b672afe3b838c
|
|
4
|
+
data.tar.gz: 791565182e62dab38561ae29eeb9794cf0f0087222449b4ef5842525b5e28424
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 973c44900f288a2ba876c9ee2df96e47e13668f271c3b5e1de187ddf4d935b4fef63de2dac2753cf1acc82628a4a4e3987c8e8e3ea8cd3e38a9ecd123e7b734e
|
|
7
|
+
data.tar.gz: e7866646881f7c217cb5b23e0adac89651e6fa47facfe9f4f33049a4e2a7362f8eb209f40dc161dbd43bdd689e643248c050be0044821efeae6abcb97c13aa08
|
|
@@ -119,6 +119,17 @@ img, svg {
|
|
|
119
119
|
border-radius: 6px;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
.env-badge {
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
color: #fff;
|
|
126
|
+
padding: 1px 6px;
|
|
127
|
+
border-radius: 4px;
|
|
128
|
+
text-transform: uppercase;
|
|
129
|
+
letter-spacing: 0.5px;
|
|
130
|
+
line-height: 1.4;
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
.site-nav__brand:hover {
|
|
123
134
|
text-decoration: none;
|
|
124
135
|
color: var(--color-primary);
|
|
@@ -268,6 +279,10 @@ img, svg {
|
|
|
268
279
|
.badge--developing { background: #dbeafe; color: var(--color-status-developing); }
|
|
269
280
|
.badge--live { background: #d1fae5; color: var(--color-status-live); }
|
|
270
281
|
.badge--abandoned { background: #f3f4f6; color: var(--color-status-abandoned); }
|
|
282
|
+
.badge--pending { background: #fef3c7; color: var(--color-status-considering); }
|
|
283
|
+
.badge--todo { background: #dbeafe; color: var(--color-status-developing); }
|
|
284
|
+
.badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
|
|
285
|
+
.badge--resolved { background: #d1fae5; color: var(--color-status-live); }
|
|
271
286
|
|
|
272
287
|
/* Forms */
|
|
273
288
|
.form-group {
|
|
@@ -619,6 +634,24 @@ img, svg {
|
|
|
619
634
|
background: rgba(108, 140, 255, 0.22);
|
|
620
635
|
}
|
|
621
636
|
|
|
637
|
+
.anchor-highlight--pending {
|
|
638
|
+
background: rgba(245, 158, 11, 0.12);
|
|
639
|
+
border-bottom: 2px solid rgba(245, 158, 11, 0.6);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.anchor-highlight--pending:hover {
|
|
643
|
+
background: rgba(245, 158, 11, 0.22);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.anchor-highlight--todo {
|
|
647
|
+
background: rgba(59, 130, 246, 0.12);
|
|
648
|
+
border-bottom: 2px solid rgba(59, 130, 246, 0.6);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.anchor-highlight--todo:hover {
|
|
652
|
+
background: rgba(59, 130, 246, 0.22);
|
|
653
|
+
}
|
|
654
|
+
|
|
622
655
|
.anchor-highlight--resolved {
|
|
623
656
|
background: none;
|
|
624
657
|
border-bottom: none;
|
|
@@ -1004,24 +1037,47 @@ img, svg {
|
|
|
1004
1037
|
/* Margin dots */
|
|
1005
1038
|
.margin-dot {
|
|
1006
1039
|
position: absolute;
|
|
1007
|
-
width:
|
|
1008
|
-
height:
|
|
1040
|
+
width: 10px;
|
|
1041
|
+
height: 10px;
|
|
1009
1042
|
border-radius: 50%;
|
|
1043
|
+
border: 2px solid transparent;
|
|
1044
|
+
background-clip: padding-box;
|
|
1010
1045
|
cursor: pointer;
|
|
1011
1046
|
left: 14px;
|
|
1012
|
-
transition: transform 0.15s, box-shadow 0.15s;
|
|
1047
|
+
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
|
1013
1048
|
display: flex;
|
|
1014
1049
|
align-items: center;
|
|
1015
1050
|
justify-content: center;
|
|
1051
|
+
padding: 0;
|
|
1052
|
+
outline: none;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.margin-dot::after {
|
|
1056
|
+
content: "";
|
|
1057
|
+
position: absolute;
|
|
1058
|
+
inset: -8px;
|
|
1059
|
+
border-radius: 50%;
|
|
1016
1060
|
}
|
|
1017
1061
|
|
|
1018
1062
|
.margin-dot:hover {
|
|
1019
|
-
transform: scale(1.
|
|
1063
|
+
transform: scale(1.4);
|
|
1020
1064
|
}
|
|
1021
1065
|
|
|
1022
|
-
.margin-dot
|
|
1023
|
-
|
|
1024
|
-
|
|
1066
|
+
.margin-dot:focus-visible {
|
|
1067
|
+
outline: 2px solid var(--color-primary);
|
|
1068
|
+
outline-offset: 2px;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
.margin-dot--pending {
|
|
1072
|
+
background: var(--color-status-considering);
|
|
1073
|
+
border-color: rgba(245, 158, 11, 0.3);
|
|
1074
|
+
box-shadow: 0 0 6px rgba(245, 158, 11, 0.25);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.margin-dot--todo {
|
|
1078
|
+
background: var(--color-status-developing);
|
|
1079
|
+
border-color: rgba(59, 130, 246, 0.3);
|
|
1080
|
+
box-shadow: 0 0 6px rgba(59, 130, 246, 0.25);
|
|
1025
1081
|
}
|
|
1026
1082
|
|
|
1027
1083
|
.margin-dot--resolved {
|
|
@@ -281,6 +281,44 @@ module CoPlan
|
|
|
281
281
|
return
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
|
+
when "replace_section"
|
|
285
|
+
return unless op["heading"]
|
|
286
|
+
include_heading = op.fetch("include_heading", true)
|
|
287
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
288
|
+
|
|
289
|
+
transformed_ranges.each do |tr|
|
|
290
|
+
if include_heading
|
|
291
|
+
# Verify the heading is the first line of the section range
|
|
292
|
+
first_line_end = content.index("\n", tr[0]) || tr[1]
|
|
293
|
+
first_line = content[tr[0]...[first_line_end, tr[1]].min]
|
|
294
|
+
unless first_line&.rstrip == op["heading"]&.rstrip
|
|
295
|
+
render json: {
|
|
296
|
+
error: "Conflict: section at target position has changed",
|
|
297
|
+
current_revision: @plan.current_revision,
|
|
298
|
+
expected_heading: op["heading"],
|
|
299
|
+
found: content[tr[0]...tr[1]]&.slice(0, 200)
|
|
300
|
+
}, status: :conflict
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
# Body-only: verify the heading appears on the line before tr[0].
|
|
305
|
+
# Walk backwards past any blank lines to find the heading text.
|
|
306
|
+
search_pos = tr[0]
|
|
307
|
+
search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
|
|
308
|
+
heading_line_end = search_pos
|
|
309
|
+
heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
|
|
310
|
+
heading_text = content[heading_line_start...heading_line_end]
|
|
311
|
+
unless heading_text == op["heading"]
|
|
312
|
+
render json: {
|
|
313
|
+
error: "Conflict: section heading before target position has changed",
|
|
314
|
+
current_revision: @plan.current_revision,
|
|
315
|
+
expected_heading: op["heading"],
|
|
316
|
+
found: heading_text
|
|
317
|
+
}, status: :conflict
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
284
322
|
end
|
|
285
323
|
end
|
|
286
324
|
|
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
module CoPlan
|
|
2
2
|
module ApplicationHelper
|
|
3
|
+
FAVICON_COLORS = {
|
|
4
|
+
"production" => { start: "#3B82F6", stop: "#1E40AF" },
|
|
5
|
+
"staging" => { start: "#F59E0B", stop: "#D97706" },
|
|
6
|
+
"development" => { start: "#10B981", stop: "#047857" },
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def coplan_favicon_tag
|
|
10
|
+
colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
|
|
11
|
+
svg = <<~SVG
|
|
12
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
13
|
+
<defs>
|
|
14
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
15
|
+
<stop offset="0%" stop-color="#{colors[:start]}"/>
|
|
16
|
+
<stop offset="100%" stop-color="#{colors[:stop]}"/>
|
|
17
|
+
</linearGradient>
|
|
18
|
+
</defs>
|
|
19
|
+
<rect width="100" height="100" rx="22" fill="url(#g)"/>
|
|
20
|
+
<g opacity=".25" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
|
|
21
|
+
<line x1="16" y1="18" x2="84" y2="18"/>
|
|
22
|
+
<line x1="16" y1="28" x2="84" y2="28"/>
|
|
23
|
+
<line x1="16" y1="38" x2="84" y2="38"/>
|
|
24
|
+
<line x1="16" y1="48" x2="84" y2="48"/>
|
|
25
|
+
<line x1="16" y1="58" x2="84" y2="58"/>
|
|
26
|
+
<line x1="16" y1="68" x2="84" y2="68"/>
|
|
27
|
+
<line x1="16" y1="78" x2="84" y2="78"/>
|
|
28
|
+
</g>
|
|
29
|
+
<circle cx="40" cy="46" r="22" fill="#fff" opacity=".55"/>
|
|
30
|
+
<path d="M26,62 L18,78 L36,66 Z" fill="#fff" opacity=".55"/>
|
|
31
|
+
<circle cx="62" cy="54" r="22" fill="#fff" opacity=".55"/>
|
|
32
|
+
<path d="M76,70 L84,84 L68,72 Z" fill="#fff" opacity=".55"/>
|
|
33
|
+
</svg>
|
|
34
|
+
SVG
|
|
35
|
+
|
|
36
|
+
data_uri = "data:image/svg+xml,#{ERB::Util.url_encode(svg.strip)}"
|
|
37
|
+
tag.link(rel: "icon", type: "image/svg+xml", href: data_uri)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def coplan_environment_badge
|
|
41
|
+
return if Rails.env.production?
|
|
42
|
+
|
|
43
|
+
label = Rails.env.capitalize
|
|
44
|
+
colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
|
|
45
|
+
tag.span(label, class: "env-badge", style: "background: #{colors[:start]};")
|
|
46
|
+
end
|
|
3
47
|
end
|
|
4
48
|
end
|
|
@@ -6,13 +6,18 @@ export default class extends Controller {
|
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
this.currentIndex = -1
|
|
9
|
+
this.activeMark = null
|
|
10
|
+
this.activePopover = null
|
|
9
11
|
this.updatePosition()
|
|
10
12
|
this.handleKeydown = this.handleKeydown.bind(this)
|
|
13
|
+
this.handleScroll = this.handleScroll.bind(this)
|
|
11
14
|
document.addEventListener("keydown", this.handleKeydown)
|
|
15
|
+
window.addEventListener("scroll", this.handleScroll, { passive: true })
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
disconnect() {
|
|
15
19
|
document.removeEventListener("keydown", this.handleKeydown)
|
|
20
|
+
window.removeEventListener("scroll", this.handleScroll)
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
handleKeydown(event) {
|
|
@@ -36,6 +41,14 @@ export default class extends Controller {
|
|
|
36
41
|
event.preventDefault()
|
|
37
42
|
this.focusReply()
|
|
38
43
|
break
|
|
44
|
+
case "a":
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
this.acceptCurrent()
|
|
47
|
+
break
|
|
48
|
+
case "d":
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
this.discardCurrent()
|
|
51
|
+
break
|
|
39
52
|
}
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -69,42 +82,142 @@ export default class extends Controller {
|
|
|
69
82
|
el.classList.remove("anchor-highlight--active")
|
|
70
83
|
})
|
|
71
84
|
|
|
85
|
+
// Close any currently open popover first — showPopover() throws
|
|
86
|
+
// InvalidStateError if the same popover is already open, and
|
|
87
|
+
// auto-dismiss only works when showing a *different* popover.
|
|
88
|
+
const openPopover = this.findOpenPopover()
|
|
89
|
+
if (openPopover) {
|
|
90
|
+
try { openPopover.hidePopover() } catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
// Add active class and scroll into view
|
|
73
94
|
mark.classList.add("anchor-highlight--active")
|
|
74
|
-
mark.scrollIntoView({ behavior: "
|
|
95
|
+
mark.scrollIntoView({ behavior: "instant", block: "center" })
|
|
75
96
|
|
|
76
97
|
// Open the thread popover if there's one
|
|
77
98
|
const threadId = mark.dataset.threadId
|
|
78
99
|
if (threadId) {
|
|
79
100
|
const popover = document.getElementById(`${threadId}_popover`)
|
|
80
101
|
if (popover) {
|
|
81
|
-
|
|
102
|
+
popover.style.visibility = "hidden"
|
|
82
103
|
popover.showPopover()
|
|
83
|
-
popover
|
|
84
|
-
popover.style.
|
|
104
|
+
this.positionPopoverAtMark(popover, mark)
|
|
105
|
+
popover.style.visibility = "visible"
|
|
106
|
+
this.activeMark = mark
|
|
107
|
+
this.activePopover = popover
|
|
85
108
|
}
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
this.updatePosition()
|
|
89
112
|
}
|
|
90
113
|
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
handleScroll() {
|
|
115
|
+
if (!this.activeMark || !this.activePopover) return
|
|
93
116
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
if (!this.activePopover.matches(":popover-open")) {
|
|
118
|
+
this.activeMark = null
|
|
119
|
+
this.activePopover = null
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
} catch { return }
|
|
123
|
+
this.positionPopoverAtMark(this.activePopover, this.activeMark)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
positionPopoverAtMark(popover, mark) {
|
|
127
|
+
const markRect = mark.getBoundingClientRect()
|
|
128
|
+
const popoverRect = popover.getBoundingClientRect()
|
|
129
|
+
const viewportWidth = window.innerWidth
|
|
130
|
+
const viewportHeight = window.innerHeight
|
|
131
|
+
|
|
132
|
+
let top = markRect.top
|
|
133
|
+
let left = markRect.right + 12
|
|
134
|
+
|
|
135
|
+
if (left + popoverRect.width > viewportWidth - 16) {
|
|
136
|
+
left = markRect.left - popoverRect.width - 12
|
|
137
|
+
}
|
|
138
|
+
if (top + popoverRect.height > viewportHeight - 16) {
|
|
139
|
+
top = viewportHeight - popoverRect.height - 16
|
|
99
140
|
}
|
|
100
|
-
if (
|
|
141
|
+
if (top < 16) top = 16
|
|
142
|
+
if (left < 16) left = 16
|
|
101
143
|
|
|
102
|
-
|
|
144
|
+
popover.style.top = `${top}px`
|
|
145
|
+
popover.style.left = `${left}px`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
focusReply() {
|
|
149
|
+
const popover = this.findOpenPopover()
|
|
150
|
+
if (!popover) return
|
|
151
|
+
|
|
152
|
+
const textarea = popover.querySelector(".thread-popover__reply textarea")
|
|
103
153
|
if (textarea) {
|
|
104
154
|
textarea.focus({ preventScroll: true })
|
|
105
155
|
}
|
|
106
156
|
}
|
|
107
157
|
|
|
158
|
+
acceptCurrent() {
|
|
159
|
+
this.submitPopoverAction("accept")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
discardCurrent() {
|
|
163
|
+
this.submitPopoverAction("discard")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
submitPopoverAction(action) {
|
|
167
|
+
const popover = this.findOpenPopover()
|
|
168
|
+
if (!popover) return
|
|
169
|
+
|
|
170
|
+
const form = popover.querySelector(`form[data-action-name='${action}']`)
|
|
171
|
+
if (!form) return
|
|
172
|
+
|
|
173
|
+
// Normalize currentIndex if popover was opened via mouse (not j/k)
|
|
174
|
+
if (this.currentIndex < 0) {
|
|
175
|
+
this.currentIndex = 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// For accept (pending→todo), the thread stays open so we need to
|
|
179
|
+
// explicitly advance. For discard, the thread leaves openHighlights
|
|
180
|
+
// and the current index naturally points to the next one.
|
|
181
|
+
const shouldAdvance = action === "accept"
|
|
182
|
+
|
|
183
|
+
// Watch for the broadcast DOM update that replaces the thread data,
|
|
184
|
+
// then advance to the next thread once the highlights have changed.
|
|
185
|
+
const threadsContainer = document.getElementById("plan-threads")
|
|
186
|
+
if (threadsContainer) {
|
|
187
|
+
const observer = new MutationObserver(() => {
|
|
188
|
+
observer.disconnect()
|
|
189
|
+
this.advanceAfterAction(shouldAdvance)
|
|
190
|
+
})
|
|
191
|
+
observer.observe(threadsContainer, { childList: true, subtree: true })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
form.requestSubmit()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
advanceAfterAction(shouldAdvance) {
|
|
198
|
+
const highlights = this.openHighlights
|
|
199
|
+
if (highlights.length === 0) {
|
|
200
|
+
this.currentIndex = -1
|
|
201
|
+
this.updatePosition()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (shouldAdvance) {
|
|
205
|
+
this.currentIndex = (this.currentIndex + 1) % highlights.length
|
|
206
|
+
} else if (this.currentIndex >= highlights.length) {
|
|
207
|
+
this.currentIndex = 0
|
|
208
|
+
}
|
|
209
|
+
this.navigateTo(highlights[this.currentIndex])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
findOpenPopover() {
|
|
213
|
+
try {
|
|
214
|
+
return document.querySelector(".thread-popover:popover-open")
|
|
215
|
+
} catch {
|
|
216
|
+
return Array.from(document.querySelectorAll(".thread-popover[popover]"))
|
|
217
|
+
.find(el => el.checkVisibility?.())
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
108
221
|
toggleResolved() {
|
|
109
222
|
const planLayout = document.querySelector(".plan-layout")
|
|
110
223
|
if (!planLayout) return
|
|
@@ -6,8 +6,12 @@ export default class extends Controller {
|
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
this.selectedText = null
|
|
9
|
+
this._activeMark = null
|
|
10
|
+
this._activePopover = null
|
|
9
11
|
this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
10
12
|
document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
13
|
+
this._handleScroll = this._handleScroll.bind(this)
|
|
14
|
+
window.addEventListener("scroll", this._handleScroll, { passive: true })
|
|
11
15
|
this.highlightAnchors()
|
|
12
16
|
|
|
13
17
|
// Watch for broadcast-appended threads and re-highlight
|
|
@@ -20,6 +24,7 @@ export default class extends Controller {
|
|
|
20
24
|
disconnect() {
|
|
21
25
|
this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
22
26
|
document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
27
|
+
window.removeEventListener("scroll", this._handleScroll)
|
|
23
28
|
if (this._threadsObserver) {
|
|
24
29
|
this._threadsObserver.disconnect()
|
|
25
30
|
this._threadsObserver = null
|
|
@@ -40,17 +45,33 @@ export default class extends Controller {
|
|
|
40
45
|
|
|
41
46
|
checkSelection(event) {
|
|
42
47
|
const selection = window.getSelection()
|
|
43
|
-
const text = selection.toString().trim()
|
|
44
48
|
|
|
45
|
-
if (
|
|
46
|
-
|
|
49
|
+
if (!selection.rangeCount) return
|
|
50
|
+
const range = selection.getRangeAt(0)
|
|
51
|
+
|
|
52
|
+
// Make sure at least part of the selection is within the content area.
|
|
53
|
+
// Whole-line selections (e.g. triple-click) can set commonAncestorContainer
|
|
54
|
+
// to a parent element above contentTarget, so we check start/end individually.
|
|
55
|
+
const startInContent = this.contentTarget.contains(range.startContainer)
|
|
56
|
+
const endInContent = this.contentTarget.contains(range.endContainer)
|
|
57
|
+
if (!startInContent) {
|
|
47
58
|
return
|
|
48
59
|
}
|
|
49
60
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
// Clamp the range to the last rendered markdown element, not the
|
|
62
|
+
// content wrapper's lastChild (which is a hidden popover/form control).
|
|
63
|
+
if (startInContent && !endInContent) {
|
|
64
|
+
const clampTarget = this.hasPopoverTarget
|
|
65
|
+
? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
|
|
66
|
+
: this.contentTarget.lastChild
|
|
67
|
+
if (clampTarget) range.setEndAfter(clampTarget)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Extract text after clamping so it only contains content-area text
|
|
71
|
+
const text = selection.toString().trim()
|
|
72
|
+
|
|
73
|
+
if (text.length < 3) {
|
|
74
|
+
this.popoverTarget.style.display = "none"
|
|
54
75
|
return
|
|
55
76
|
}
|
|
56
77
|
|
|
@@ -111,7 +132,10 @@ export default class extends Controller {
|
|
|
111
132
|
if (event.detail.success) {
|
|
112
133
|
const form = event.target
|
|
113
134
|
const textarea = form.querySelector("textarea")
|
|
114
|
-
if (textarea)
|
|
135
|
+
if (textarea) {
|
|
136
|
+
textarea.value = ""
|
|
137
|
+
textarea.blur()
|
|
138
|
+
}
|
|
115
139
|
}
|
|
116
140
|
}
|
|
117
141
|
|
|
@@ -155,40 +179,49 @@ export default class extends Controller {
|
|
|
155
179
|
const popover = document.getElementById(`${threadId}_popover`)
|
|
156
180
|
if (!popover) return
|
|
157
181
|
|
|
158
|
-
// Position the popover near the clicked element
|
|
159
182
|
const trigger = event.currentTarget
|
|
160
|
-
const triggerRect = trigger.getBoundingClientRect()
|
|
161
183
|
|
|
162
|
-
// Hide visually while positioning to prevent flash
|
|
163
184
|
popover.style.visibility = "hidden"
|
|
164
185
|
popover.showPopover()
|
|
186
|
+
this._positionPopoverAtMark(popover, trigger)
|
|
187
|
+
popover.style.visibility = "visible"
|
|
188
|
+
|
|
189
|
+
this._activeMark = trigger
|
|
190
|
+
this._activePopover = popover
|
|
191
|
+
}
|
|
165
192
|
|
|
166
|
-
|
|
193
|
+
_handleScroll() {
|
|
194
|
+
if (!this._activeMark || !this._activePopover) return
|
|
195
|
+
try {
|
|
196
|
+
if (!this._activePopover.matches(":popover-open")) {
|
|
197
|
+
this._activeMark = null
|
|
198
|
+
this._activePopover = null
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
} catch { return }
|
|
202
|
+
this._positionPopoverAtMark(this._activePopover, this._activeMark)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_positionPopoverAtMark(popover, mark) {
|
|
206
|
+
const markRect = mark.getBoundingClientRect()
|
|
167
207
|
const popoverRect = popover.getBoundingClientRect()
|
|
168
208
|
const viewportWidth = window.innerWidth
|
|
169
209
|
const viewportHeight = window.innerHeight
|
|
170
210
|
|
|
171
|
-
|
|
172
|
-
let
|
|
173
|
-
let left = triggerRect.right + 12
|
|
211
|
+
let top = markRect.top
|
|
212
|
+
let left = markRect.right + 12
|
|
174
213
|
|
|
175
|
-
// If it would overflow right, position to the left
|
|
176
214
|
if (left + popoverRect.width > viewportWidth - 16) {
|
|
177
|
-
left =
|
|
215
|
+
left = markRect.left - popoverRect.width - 12
|
|
178
216
|
}
|
|
179
|
-
|
|
180
|
-
// If it would overflow bottom, shift up
|
|
181
217
|
if (top + popoverRect.height > viewportHeight - 16) {
|
|
182
218
|
top = viewportHeight - popoverRect.height - 16
|
|
183
219
|
}
|
|
184
|
-
|
|
185
|
-
// Ensure it doesn't go outside viewport
|
|
186
220
|
if (top < 16) top = 16
|
|
187
221
|
if (left < 16) left = 16
|
|
188
222
|
|
|
189
223
|
popover.style.top = `${top}px`
|
|
190
224
|
popover.style.left = `${left}px`
|
|
191
|
-
popover.style.visibility = "visible"
|
|
192
225
|
}
|
|
193
226
|
|
|
194
227
|
extractContext(range, selectedText) {
|
|
@@ -276,7 +309,9 @@ export default class extends Controller {
|
|
|
276
309
|
if (anchor && anchor.length > 0) {
|
|
277
310
|
const isOpen = status === "pending" || status === "todo"
|
|
278
311
|
const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
|
|
279
|
-
const
|
|
312
|
+
const specificClass = isOpen ? `anchor-highlight--${status}` : ""
|
|
313
|
+
const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
|
|
314
|
+
const mark = this.findAndHighlight(anchor, occurrence, classes)
|
|
280
315
|
|
|
281
316
|
if (mark && threadId) {
|
|
282
317
|
// Make highlight clickable to open popover
|
|
@@ -300,11 +335,12 @@ export default class extends Controller {
|
|
|
300
335
|
|
|
301
336
|
const dot = document.createElement("button")
|
|
302
337
|
const isOpen = status === "pending" || status === "todo"
|
|
303
|
-
|
|
338
|
+
const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
|
|
339
|
+
dot.className = `margin-dot ${openClass}`
|
|
304
340
|
dot.style.top = `${markRect.top - marginRect.top}px`
|
|
305
341
|
dot.dataset.threadId = threadId
|
|
306
342
|
dot.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
307
|
-
dot.
|
|
343
|
+
dot.setAttribute("aria-label", `${status} comment`)
|
|
308
344
|
|
|
309
345
|
this.marginTarget.appendChild(dot)
|
|
310
346
|
}
|
|
@@ -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
|
|
@@ -20,6 +20,8 @@ module CoPlan
|
|
|
20
20
|
resolve_insert_under_heading
|
|
21
21
|
when "delete_paragraph_containing"
|
|
22
22
|
resolve_delete_paragraph_containing
|
|
23
|
+
when "replace_section"
|
|
24
|
+
resolve_replace_section
|
|
23
25
|
else
|
|
24
26
|
raise OperationError, "Unknown operation: #{@op["op"]}"
|
|
25
27
|
end
|
|
@@ -153,6 +155,115 @@ module CoPlan
|
|
|
153
155
|
paragraphs
|
|
154
156
|
end
|
|
155
157
|
|
|
158
|
+
def resolve_replace_section
|
|
159
|
+
heading = @op["heading"]
|
|
160
|
+
raise OperationError, "replace_section requires 'heading'" if heading.blank?
|
|
161
|
+
raise OperationError, "replace_section requires 'new_content'" if @op["new_content"].nil?
|
|
162
|
+
|
|
163
|
+
include_heading = @op.fetch("include_heading", true)
|
|
164
|
+
# Normalize: accept both string and boolean
|
|
165
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
166
|
+
|
|
167
|
+
headings = parse_headings(@content)
|
|
168
|
+
matches = headings.select { |h| h[:text] == heading }
|
|
169
|
+
|
|
170
|
+
if matches.empty?
|
|
171
|
+
raise OperationError, "replace_section: heading_not_found — no heading matching '#{heading}'"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if matches.length > 1
|
|
175
|
+
match_details = matches.map { |m| { heading: m[:text], line: m[:line] } }
|
|
176
|
+
raise OperationError, "replace_section: ambiguous_heading — found #{matches.length} headings matching '#{heading}': #{match_details.inspect}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
match = matches.first
|
|
180
|
+
target_level = match[:level]
|
|
181
|
+
|
|
182
|
+
# Section starts at the heading line start
|
|
183
|
+
section_start = match[:line_start]
|
|
184
|
+
|
|
185
|
+
# Section ends at the next heading of equal or higher level, or EOF
|
|
186
|
+
next_heading = headings.find { |h| h[:line_start] > match[:line_start] && h[:level] <= target_level }
|
|
187
|
+
section_end = next_heading ? next_heading[:line_start] : @content.length
|
|
188
|
+
|
|
189
|
+
# Strip all trailing newlines from the section range so the separator
|
|
190
|
+
# between sections falls outside the replaced range. This ensures
|
|
191
|
+
# replacement content won't merge into the next heading.
|
|
192
|
+
section_end = section_end.to_i
|
|
193
|
+
while section_end > section_start && @content[section_end - 1] == "\n"
|
|
194
|
+
section_end -= 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
range = if include_heading
|
|
198
|
+
[section_start, section_end]
|
|
199
|
+
else
|
|
200
|
+
# Skip past the heading line itself
|
|
201
|
+
heading_line_end = @content.index("\n", section_start)
|
|
202
|
+
if heading_line_end
|
|
203
|
+
body_start = heading_line_end + 1
|
|
204
|
+
# Skip blank line after heading
|
|
205
|
+
while body_start < section_end && @content[body_start] == "\n"
|
|
206
|
+
body_start += 1
|
|
207
|
+
end
|
|
208
|
+
# When trailing newlines are stripped, section_end can retreat
|
|
209
|
+
# behind body_start. Use an empty range at body_start to avoid
|
|
210
|
+
# an inverted range and keep the insertion point after the heading newline.
|
|
211
|
+
[body_start, [body_start, section_end].max]
|
|
212
|
+
else
|
|
213
|
+
# Heading is the only line — body is empty
|
|
214
|
+
[section_end, section_end]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
Resolution.new(op: "replace_section", ranges: [range])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Parse markdown headings, respecting code fences (``` blocks).
|
|
222
|
+
# Returns an array of hashes: { text:, level:, line:, line_start:, line_end: }
|
|
223
|
+
def parse_headings(content)
|
|
224
|
+
headings = []
|
|
225
|
+
in_code_fence = false
|
|
226
|
+
fence_char = nil
|
|
227
|
+
fence_length = 0
|
|
228
|
+
line_number = 0
|
|
229
|
+
pos = 0
|
|
230
|
+
|
|
231
|
+
content.each_line do |line|
|
|
232
|
+
line_number += 1
|
|
233
|
+
line_start = pos
|
|
234
|
+
line_end = pos + line.length
|
|
235
|
+
stripped = line.chomp
|
|
236
|
+
|
|
237
|
+
fence_match = stripped.match(/\A(`{3,}|~{3,})(.*)\z/)
|
|
238
|
+
if fence_match
|
|
239
|
+
fence_chars = fence_match[1]
|
|
240
|
+
info_string = fence_match[2]
|
|
241
|
+
if in_code_fence
|
|
242
|
+
# Close only if fence char and length match, and no info string
|
|
243
|
+
if fence_chars[0] == fence_char && fence_chars.length >= fence_length && info_string.strip.empty?
|
|
244
|
+
in_code_fence = false
|
|
245
|
+
end
|
|
246
|
+
else
|
|
247
|
+
in_code_fence = true
|
|
248
|
+
fence_char = fence_chars[0]
|
|
249
|
+
fence_length = fence_chars.length
|
|
250
|
+
end
|
|
251
|
+
elsif !in_code_fence && (m = stripped.match(/\A(\#{1,6})\s+(.+)/))
|
|
252
|
+
headings << {
|
|
253
|
+
level: m[1].length,
|
|
254
|
+
text: stripped,
|
|
255
|
+
line: line_number,
|
|
256
|
+
line_start: line_start,
|
|
257
|
+
line_end: line_end
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
pos = line_end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
headings
|
|
265
|
+
end
|
|
266
|
+
|
|
156
267
|
# Determine the character range to delete so that removing
|
|
157
268
|
# content[range[0]...range[1]] produces clean output with
|
|
158
269
|
# correct paragraph spacing.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
|
|
2
2
|
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
3
3
|
<div class="form-group">
|
|
4
|
-
<textarea name="comment[body_markdown]" rows="2" placeholder="
|
|
4
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
|
|
5
5
|
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
6
6
|
</div>
|
|
7
7
|
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
|
|
9
9
|
<div class="thread-popover__header">
|
|
10
|
-
<span class="badge badge--<%= thread.
|
|
10
|
+
<span class="badge badge--<%= thread.status %>"><%= thread.status %></span>
|
|
11
11
|
<% if thread.out_of_date? %>
|
|
12
12
|
<span class="badge badge--abandoned">out of date</span>
|
|
13
13
|
<% end %>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<div class="thread-popover__reply">
|
|
33
33
|
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
34
34
|
<div class="form-group">
|
|
35
|
-
<textarea name="comment[body_markdown]" rows="2" placeholder="
|
|
35
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
|
|
36
36
|
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
37
37
|
</div>
|
|
38
38
|
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
<div class="thread-popover__actions">
|
|
43
43
|
<% if is_plan_author %>
|
|
44
44
|
<% if thread.status == "pending" %>
|
|
45
|
-
<%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
45
|
+
<%= button_to "Accept (a)", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "accept" } %>
|
|
46
46
|
<% end %>
|
|
47
|
-
<%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
47
|
+
<%= button_to "Discard (d)", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "discard" } %>
|
|
48
48
|
<% end %>
|
|
49
49
|
</div>
|
|
50
50
|
<% else %>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
|
+
<%= coplan_favicon_tag %>
|
|
8
9
|
<%= yield :head %>
|
|
9
10
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
<%= link_to coplan.root_path, class: "site-nav__brand" do %>
|
|
19
20
|
<%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
|
|
20
21
|
CoPlan
|
|
22
|
+
<%= coplan_environment_badge %>
|
|
21
23
|
<% end %>
|
|
22
24
|
<ul class="site-nav__links">
|
|
23
25
|
<% if signed_in? %>
|
data/lib/coplan/version.rb
CHANGED