pinmark 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,857 @@
1
+ // app/javascript/controllers/pinmark_controller.js
2
+ import { Controller } from "@hotwired/stimulus"
3
+
4
+ const ACTIVE_FLAG = "pinmark"
5
+ const MODE_FLAG = "pinmark_mode"
6
+ const COLLAPSED_FLAG = "pinmark_panel_collapsed"
7
+ // URLs are relative to the engine mount path. The default mount in the
8
+ // install generator is /dev/pinmark.
9
+ const CREATE_URL = "/dev/pinmark/annotations"
10
+ const INDEX_URL = "/dev/pinmark/annotations"
11
+ const DELETE_URL = (id) => `/dev/pinmark/annotations/${encodeURIComponent(id)}`
12
+
13
+ export default class extends Controller {
14
+ static targets = ["panel", "popoverTemplate", "popoverInput", "popoverHeader", "popoverDebug", "modeButton"]
15
+
16
+ connect() {
17
+ this.tree = this._readBootstrapTree()
18
+ this.nodesById = this._indexTree(this.tree)
19
+ this._enrichDom()
20
+
21
+ const url = new URL(window.location.href)
22
+ if (url.searchParams.get("annotate") === "1") {
23
+ localStorage.setItem(ACTIVE_FLAG, "1")
24
+ this._setCookie(ACTIVE_FLAG, "1")
25
+ }
26
+
27
+ this.serverComments = []
28
+ this.active = localStorage.getItem(ACTIVE_FLAG) === "1"
29
+ this.targetMode = localStorage.getItem(MODE_FLAG) === "component" ? "component" : "element"
30
+ this._panelCollapsed = localStorage.getItem(COLLAPSED_FLAG) === "1"
31
+ this._renderModeButton()
32
+ if (this.active) this._activate()
33
+ this._setToggleLabel(this.active)
34
+ this._renderPanel()
35
+ this._fetchServerComments().then(() => { this._renderPanel(); this._renderMarkers() })
36
+
37
+ let rafId = null
38
+ this._onResize = () => {
39
+ if (rafId) return
40
+ rafId = requestAnimationFrame(() => { rafId = null; this._renderMarkers() })
41
+ }
42
+ window.addEventListener("resize", this._onResize)
43
+ }
44
+
45
+ disconnect() {
46
+ this._deactivate()
47
+ if (this._onResize) window.removeEventListener("resize", this._onResize)
48
+ this._clearMarkers()
49
+ this._markersEl?.remove()
50
+ this._markersEl = null
51
+ }
52
+
53
+ toggle() {
54
+ this.active = !this.active
55
+ if (this.active) {
56
+ localStorage.setItem(ACTIVE_FLAG, "1")
57
+ this._setCookie(ACTIVE_FLAG, "1")
58
+ this._activate()
59
+ } else {
60
+ localStorage.removeItem(ACTIVE_FLAG)
61
+ this._setCookie(ACTIVE_FLAG, "", -1)
62
+ this._deactivate()
63
+ }
64
+ }
65
+
66
+ refresh() {
67
+ this._fetchServerComments().then(() => { this._renderPanel(); this._renderMarkers() })
68
+ }
69
+
70
+ savePopover() {
71
+ if (!this._popover) return
72
+ const textarea = this._popover.querySelector("textarea")
73
+ if (!textarea) { this._closePopover(); return }
74
+ const { nodeId, selector } = this._popoverContext
75
+
76
+ const value = textarea.value.trim()
77
+ if (value.length === 0) {
78
+ this._closePopover()
79
+ return
80
+ }
81
+
82
+ const excerpt = this._textExcerpt(this._currentHighlight)
83
+
84
+ const payload = {
85
+ path: window.location.pathname,
86
+ capturedAt: new Date().toISOString(),
87
+ tree: this.tree,
88
+ replace_match: true,
89
+ comments: [{
90
+ node_id: nodeId,
91
+ selector,
92
+ comment: value,
93
+ component: this.nodesById[nodeId]?.component || "(page-level)",
94
+ source: this.nodesById[nodeId]?.source || null,
95
+ text_excerpt: excerpt,
96
+ captured_at: new Date().toISOString(),
97
+ }],
98
+ }
99
+
100
+ fetch(CREATE_URL, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify(payload),
104
+ })
105
+ .then((r) => r.json())
106
+ .then(() => this._fetchServerComments())
107
+ .then(() => { this._renderPanel(); this._renderMarkers() })
108
+ .catch((err) => alert(`Save failed: ${err.message || err}`))
109
+
110
+ this._closePopover()
111
+ }
112
+
113
+ cancelPopover() {
114
+ this._closePopover()
115
+ }
116
+
117
+ cycleTargetMode() {
118
+ this.targetMode = this.targetMode === "component" ? "element" : "component"
119
+ localStorage.setItem(MODE_FLAG, this.targetMode)
120
+ this._renderModeButton()
121
+ }
122
+
123
+ _renderModeButton() {
124
+ if (!this.hasModeButtonTarget) return
125
+ const isElement = this.targetMode === "element"
126
+ this.modeButtonTarget.textContent = isElement ? "Mode: Element" : "Mode: Component"
127
+ this.modeButtonTarget.classList.toggle("is-element", isElement)
128
+ }
129
+
130
+ // --- private ---
131
+
132
+ _activate() {
133
+ this._onMove = (e) => this._highlightFromEvent(e)
134
+ this._onClick = (e) => this._maybeOpenPopover(e)
135
+ this._onMouseDown = (e) => this._marqueeMaybeStart(e)
136
+ this._onMouseUp = (e) => this._marqueeMaybeEnd(e)
137
+ document.addEventListener("mousemove", this._onMove, true)
138
+ document.addEventListener("click", this._onClick, true)
139
+ document.addEventListener("mousedown", this._onMouseDown, true)
140
+ document.addEventListener("mouseup", this._onMouseUp, true)
141
+ this.panelTarget.classList.add("is-open")
142
+ this._setToggleLabel(true)
143
+ this._ensureLabel()
144
+ }
145
+
146
+ _deactivate() {
147
+ if (this._onMove) document.removeEventListener("mousemove", this._onMove, true)
148
+ if (this._onClick) document.removeEventListener("click", this._onClick, true)
149
+ if (this._onMouseDown) document.removeEventListener("mousedown", this._onMouseDown, true)
150
+ if (this._onMouseUp) document.removeEventListener("mouseup", this._onMouseUp, true)
151
+ this._clearMarquee()
152
+ this._clearHighlight()
153
+ this.panelTarget?.classList.remove("is-open")
154
+ this._setToggleLabel(false)
155
+ this._labelEl?.remove()
156
+ this._labelEl = null
157
+ }
158
+
159
+ // --- marquee select ---
160
+
161
+ _marqueeMaybeStart(e) {
162
+ if (!this.active) return
163
+ if (!e.shiftKey) return
164
+ if (e.button !== 0) return
165
+ if (this.element.contains(e.target)) return
166
+ if (e.target.closest("#pinmark-activator")) return
167
+
168
+ e.preventDefault()
169
+ e.stopPropagation()
170
+
171
+ this._marqueeState = {
172
+ startX: e.clientX + window.scrollX,
173
+ startY: e.clientY + window.scrollY,
174
+ }
175
+
176
+ const box = document.createElement("div")
177
+ box.className = "pinmark-marquee"
178
+ document.body.appendChild(box)
179
+ this._marqueeBox = box
180
+
181
+ this._marqueeMoveHandler = (ev) => this._marqueeUpdate(ev)
182
+ document.addEventListener("mousemove", this._marqueeMoveHandler, true)
183
+ }
184
+
185
+ _marqueeUpdate(e) {
186
+ if (!this._marqueeState || !this._marqueeBox) return
187
+ e.preventDefault()
188
+ const curX = e.clientX + window.scrollX
189
+ const curY = e.clientY + window.scrollY
190
+ const left = Math.min(this._marqueeState.startX, curX)
191
+ const top = Math.min(this._marqueeState.startY, curY)
192
+ const width = Math.abs(curX - this._marqueeState.startX)
193
+ const height = Math.abs(curY - this._marqueeState.startY)
194
+ this._marqueeBox.style.left = `${left}px`
195
+ this._marqueeBox.style.top = `${top}px`
196
+ this._marqueeBox.style.width = `${width}px`
197
+ this._marqueeBox.style.height = `${height}px`
198
+ }
199
+
200
+ _marqueeMaybeEnd(e) {
201
+ if (!this._marqueeState) return
202
+ e.preventDefault()
203
+ e.stopPropagation()
204
+
205
+ const start = this._marqueeState
206
+ const endX = e.clientX + window.scrollX
207
+ const endY = e.clientY + window.scrollY
208
+ const rect = {
209
+ left: Math.min(start.startX, endX),
210
+ top: Math.min(start.startY, endY),
211
+ right: Math.max(start.startX, endX),
212
+ bottom: Math.max(start.startY, endY),
213
+ }
214
+ rect.width = rect.right - rect.left
215
+ rect.height = rect.bottom - rect.top
216
+
217
+ this._clearMarquee()
218
+
219
+ if (rect.width < 5 || rect.height < 5) return // accidental click
220
+
221
+ const target = this._pickElementFromMarquee(rect)
222
+ if (!target) return
223
+
224
+ const phlexId = target.dataset.pinmarkId
225
+ target.scrollIntoView({ behavior: "smooth", block: "nearest" })
226
+ this._currentHighlight = target
227
+ target.classList.add("pinmark-highlight")
228
+ setTimeout(() => target.classList.remove("pinmark-highlight"), 1200)
229
+
230
+ const tr = target.getBoundingClientRect()
231
+ this._openPopover(tr.left, tr.top, phlexId, null)
232
+ }
233
+
234
+ _clearMarquee() {
235
+ if (this._marqueeBox) {
236
+ this._marqueeBox.remove()
237
+ this._marqueeBox = null
238
+ }
239
+ if (this._marqueeMoveHandler) {
240
+ document.removeEventListener("mousemove", this._marqueeMoveHandler, true)
241
+ this._marqueeMoveHandler = null
242
+ }
243
+ this._marqueeState = null
244
+ }
245
+
246
+ _pickElementFromMarquee(marquee) {
247
+ const candidates = Array.from(document.querySelectorAll("[data-pinmark-id]"))
248
+ .map((el) => {
249
+ const r = el.getBoundingClientRect()
250
+ const docRect = {
251
+ left: r.left + window.scrollX,
252
+ top: r.top + window.scrollY,
253
+ right: r.right + window.scrollX,
254
+ bottom: r.bottom + window.scrollY,
255
+ }
256
+ const intersects = !(docRect.right < marquee.left || docRect.left > marquee.right ||
257
+ docRect.bottom < marquee.top || docRect.top > marquee.bottom)
258
+ const containedInMarquee = docRect.left >= marquee.left && docRect.top >= marquee.top &&
259
+ docRect.right <= marquee.right && docRect.bottom <= marquee.bottom
260
+ const containsMarquee = docRect.left <= marquee.left && docRect.top <= marquee.top &&
261
+ docRect.right >= marquee.right && docRect.bottom >= marquee.bottom
262
+ const area = (docRect.right - docRect.left) * (docRect.bottom - docRect.top)
263
+ return { el, area, intersects, containedInMarquee, containsMarquee }
264
+ })
265
+ .filter((c) => c.intersects)
266
+
267
+ const containedInMarquee = candidates.filter((c) => c.containedInMarquee)
268
+ if (containedInMarquee.length > 0) {
269
+ // Largest fully-contained = highest-in-hierarchy fitting visible
270
+ return containedInMarquee.sort((a, b) => b.area - a.area)[0].el
271
+ }
272
+
273
+ const containingMarquee = candidates.filter((c) => c.containsMarquee)
274
+ if (containingMarquee.length > 0) {
275
+ // Smallest enclosing ancestor
276
+ return containingMarquee.sort((a, b) => a.area - b.area)[0].el
277
+ }
278
+
279
+ return null
280
+ }
281
+
282
+ _ensureLabel() {
283
+ if (this._labelEl) return
284
+ const el = document.createElement("div")
285
+ el.className = "pinmark-label"
286
+ el.style.display = "none"
287
+ this.element.appendChild(el)
288
+ this._labelEl = el
289
+ }
290
+
291
+ _updateLabel(targetEl, isTagMode, componentEl) {
292
+ if (!this._labelEl) return
293
+ const nodeId = componentEl?.dataset.pinmarkId
294
+ const componentName = nodeId ? this.nodesById[nodeId]?.component : null
295
+ let text
296
+ if (isTagMode && componentEl) {
297
+ const selector = this._domPathRelativeTo(componentEl, targetEl)
298
+ text = `${selector} inside ${componentName || "(unknown)"}`
299
+ } else if (isTagMode) {
300
+ const selector = this._domPathRelativeTo(document.body, targetEl)
301
+ text = `${selector} (page-level)`
302
+ } else {
303
+ text = componentName || "(unknown)"
304
+ }
305
+ this._labelEl.textContent = text
306
+ this._labelEl.classList.toggle("is-tag", isTagMode)
307
+
308
+ const r = targetEl.getBoundingClientRect()
309
+ const margin = 4
310
+ let top = r.top - 22
311
+ if (top < margin) top = r.bottom + 4
312
+ let left = r.left
313
+ const vw = window.innerWidth
314
+ if (left + 200 > vw - margin) left = Math.max(margin, vw - 200 - margin)
315
+ this._labelEl.style.left = `${left}px`
316
+ this._labelEl.style.top = `${top}px`
317
+ this._labelEl.style.display = "block"
318
+ }
319
+
320
+ _setToggleLabel(active) {
321
+ const btn = this.element.querySelector(".pinmark-toggle")
322
+ if (!btn) return
323
+ btn.classList.toggle("is-active", active)
324
+ btn.textContent = active ? "Stop annotating" : "Annotate"
325
+ }
326
+
327
+ _highlightFromEvent(e) {
328
+ const altMode = e.altKey
329
+ const target = document.elementFromPoint(e.clientX, e.clientY)
330
+ if (!target) return
331
+ if (this.element.contains(target)) {
332
+ this._clearHighlight()
333
+ return
334
+ }
335
+ if (target.closest("#pinmark-activator")) {
336
+ this._clearHighlight()
337
+ return
338
+ }
339
+ const componentEl = target.closest("[data-pinmark-id]")
340
+ const wantsTag = this.targetMode === "element" ? !altMode : altMode
341
+
342
+ let isTagMode, next
343
+ if (componentEl) {
344
+ isTagMode = wantsTag && componentEl.contains(target) && target !== componentEl
345
+ next = isTagMode ? target : componentEl
346
+ } else {
347
+ // No annotated ancestor — fall through to the cursor target so layout
348
+ // wrappers, top-level divs, plain ERB pages, etc. are still hoverable.
349
+ isTagMode = true
350
+ next = target
351
+ }
352
+
353
+ if (next === this._currentHighlight) {
354
+ // Same primary highlight target, but tag-mode might have flipped
355
+ if (isTagMode && componentEl && componentEl !== next) {
356
+ if (this._currentContextHighlight !== componentEl) {
357
+ this._currentContextHighlight?.classList.remove("pinmark-highlight-context")
358
+ componentEl.classList.add("pinmark-highlight-context")
359
+ this._currentContextHighlight = componentEl
360
+ }
361
+ } else if (this._currentContextHighlight) {
362
+ this._currentContextHighlight.classList.remove("pinmark-highlight-context")
363
+ this._currentContextHighlight = null
364
+ }
365
+ this._updateLabel(next, isTagMode, componentEl)
366
+ return
367
+ }
368
+
369
+ this._clearHighlight()
370
+ next.classList.add(isTagMode ? "pinmark-highlight-tag" : "pinmark-highlight")
371
+ this._currentHighlight = next
372
+
373
+ if (isTagMode && componentEl && componentEl !== next) {
374
+ componentEl.classList.add("pinmark-highlight-context")
375
+ this._currentContextHighlight = componentEl
376
+ }
377
+
378
+ this._currentHighlightComponent = componentEl
379
+ this._updateLabel(next, isTagMode, componentEl)
380
+ }
381
+
382
+ _clearHighlight() {
383
+ if (this._currentHighlight) {
384
+ this._currentHighlight.classList.remove(
385
+ "pinmark-highlight",
386
+ "pinmark-highlight-tag"
387
+ )
388
+ }
389
+ if (this._currentContextHighlight) {
390
+ this._currentContextHighlight.classList.remove("pinmark-highlight-context")
391
+ }
392
+ this._currentHighlight = null
393
+ this._currentContextHighlight = null
394
+ this._currentHighlightComponent = null
395
+ if (this._labelEl) this._labelEl.style.display = "none"
396
+ }
397
+
398
+ _maybeOpenPopover(e) {
399
+ if (!this.active) return
400
+ if (this.element.contains(e.target)) return
401
+ if (e.target.closest("#pinmark-activator")) return
402
+ const componentEl = e.target.closest("[data-pinmark-id]")
403
+ const wantsTag = this.targetMode === "element" ? !e.altKey : e.altKey
404
+
405
+ let nodeId, selector
406
+ if (componentEl) {
407
+ const altMode = wantsTag && e.target !== componentEl
408
+ nodeId = componentEl.dataset.pinmarkId
409
+ selector = altMode ? this._domPathRelativeTo(componentEl, e.target) : null
410
+ } else {
411
+ // Page-level free target: no annotated ancestor.
412
+ nodeId = null
413
+ selector = this._domPathRelativeTo(document.body, e.target)
414
+ }
415
+
416
+ e.preventDefault()
417
+ e.stopPropagation()
418
+ this._openPopover(e.clientX, e.clientY, nodeId, selector)
419
+ }
420
+
421
+ _openPopover(x, y, nodeId, selector) {
422
+ this._closePopover()
423
+
424
+ if (!this.hasPopoverTemplateTarget || !this.popoverTemplateTarget.content.firstElementChild) {
425
+ console.warn("[pinmark] popover template missing")
426
+ return
427
+ }
428
+
429
+ const node = this.popoverTemplateTarget.content.firstElementChild.cloneNode(true)
430
+ const textarea = node.querySelector("textarea")
431
+ if (!textarea) return
432
+
433
+ textarea.addEventListener("keydown", (e) => {
434
+ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
435
+ e.preventDefault()
436
+ this.savePopover()
437
+ } else if (e.key === "Escape") {
438
+ e.preventDefault()
439
+ this.cancelPopover()
440
+ }
441
+ })
442
+
443
+ const path = window.location.pathname
444
+ const existing = (this.serverComments || []).find(
445
+ (c) => c.node_id === nodeId
446
+ && (c.selector || null) === selector
447
+ && (c.page_path || null) === path
448
+ )
449
+ if (existing) textarea.value = existing.comment
450
+ this._fillPopoverDetails(node, nodeId, selector)
451
+
452
+ // Render off-screen first to measure
453
+ node.style.left = "-9999px"
454
+ node.style.top = "-9999px"
455
+ this.element.appendChild(node)
456
+ this._popover = node
457
+
458
+ // Anchor to the highlighted element when possible; fall back to click coords
459
+ const anchor =
460
+ this._currentHighlight ||
461
+ (selector && this._currentHighlight) ||
462
+ document.querySelector(`[data-pinmark-id="${CSS.escape(nodeId)}"]`)
463
+
464
+ const margin = 8
465
+ const popoverRect = node.getBoundingClientRect()
466
+ const vw = window.innerWidth
467
+ const vh = window.innerHeight
468
+
469
+ let left, top
470
+ if (anchor) {
471
+ const a = anchor.getBoundingClientRect()
472
+ left = a.right + margin
473
+ top = a.top
474
+ if (left + popoverRect.width > vw - margin) {
475
+ left = Math.max(margin, a.left - popoverRect.width - margin)
476
+ }
477
+ if (top + popoverRect.height > vh - margin) {
478
+ top = Math.max(margin, vh - popoverRect.height - margin)
479
+ }
480
+ if (top < margin) top = margin
481
+ } else {
482
+ left = Math.min(Math.max(margin, x + margin), vw - popoverRect.width - margin)
483
+ top = Math.min(Math.max(margin, y + margin), vh - popoverRect.height - margin)
484
+ }
485
+
486
+ node.style.left = `${left}px`
487
+ node.style.top = `${top}px`
488
+
489
+ this._popoverContext = { nodeId, selector }
490
+ textarea.focus()
491
+ }
492
+
493
+ _closePopover() {
494
+ this._popover?.remove()
495
+ this._popover = null
496
+ this._popoverContext = null
497
+ }
498
+
499
+ _fillPopoverDetails(popoverNode, nodeId, selector) {
500
+ const node = nodeId ? this.nodesById[nodeId] : null
501
+
502
+ const header = popoverNode.querySelector('[data-pinmark-target="popoverHeader"]')
503
+ if (header) {
504
+ header.textContent = ""
505
+ const title = document.createElement("div")
506
+ title.textContent = node ? node.component : "(page-level element)"
507
+ header.appendChild(title)
508
+
509
+ if (node) {
510
+ const src = document.createElement("span")
511
+ src.className = "mcp-source"
512
+ src.textContent = node.source
513
+ header.appendChild(src)
514
+ }
515
+
516
+ if (selector) {
517
+ const sel = document.createElement("span")
518
+ sel.className = "mcp-selector"
519
+ sel.textContent = selector
520
+ header.appendChild(sel)
521
+ }
522
+ }
523
+
524
+ const debug = popoverNode.querySelector('[data-pinmark-target="popoverDebug"]')
525
+ if (debug) {
526
+ debug.textContent = ""
527
+ const tree = document.createElement("pre")
528
+ const lines = []
529
+ lines.push(`node_id: ${nodeId || "(none — page-level element)"}`)
530
+ lines.push(`component: ${node ? node.component : "(none)"}`)
531
+ lines.push(`source: ${node ? node.source : "(none)"}`)
532
+ if (selector) {
533
+ const label = node ? "selector (relative to component)" : "selector (relative to <body>)"
534
+ lines.push(`${label}: ${selector}`)
535
+ }
536
+ if (node) {
537
+ lines.push("")
538
+ lines.push("Component ancestry (root → leaf):")
539
+ const ancestry = this._componentAncestry(nodeId)
540
+ ancestry.forEach((n, i) => {
541
+ lines.push(`${" ".repeat(i)}└ ${n.component} (${n.id}, ${n.source})`)
542
+ })
543
+ }
544
+ tree.textContent = lines.join("\n")
545
+ debug.appendChild(tree)
546
+ }
547
+ }
548
+
549
+ _componentAncestry(nodeId) {
550
+ const chain = []
551
+ let current = this.nodesById[nodeId]
552
+ while (current) {
553
+ chain.unshift(current)
554
+ current = current.parent_id ? this.nodesById[current.parent_id] : null
555
+ }
556
+ return chain
557
+ }
558
+
559
+ _renderPanel() {
560
+ const comments = this.serverComments || []
561
+ const pending = comments.filter((c) => c.status !== "addressed")
562
+ const addressed = comments.filter((c) => c.status === "addressed")
563
+ const ordered = [...pending, ...addressed]
564
+ this.panelTarget.innerHTML = ""
565
+ this.panelTarget.classList.toggle("is-collapsed", !!this._panelCollapsed)
566
+
567
+ const headerRow = document.createElement("div")
568
+ headerRow.className = "pinmark-panel-header"
569
+
570
+ const heading = document.createElement("strong")
571
+ heading.textContent = `Annotations (${pending.length} pending`
572
+ if (addressed.length > 0) heading.textContent += ` • ${addressed.length} resolved`
573
+ heading.textContent += ")"
574
+ headerRow.appendChild(heading)
575
+
576
+ const collapseBtn = document.createElement("button")
577
+ collapseBtn.type = "button"
578
+ collapseBtn.className = "pinmark-panel-collapse"
579
+ collapseBtn.textContent = this._panelCollapsed ? "+" : "−"
580
+ collapseBtn.title = this._panelCollapsed ? "Expand panel" : "Collapse panel"
581
+ collapseBtn.addEventListener("click", () => {
582
+ this._panelCollapsed = !this._panelCollapsed
583
+ if (this._panelCollapsed) {
584
+ localStorage.setItem(COLLAPSED_FLAG, "1")
585
+ } else {
586
+ localStorage.removeItem(COLLAPSED_FLAG)
587
+ }
588
+ this._renderPanel()
589
+ })
590
+ headerRow.appendChild(collapseBtn)
591
+
592
+ this.panelTarget.appendChild(headerRow)
593
+
594
+ if (this._panelCollapsed) return
595
+
596
+ ordered.forEach((c) => {
597
+ const isResolved = c.status === "addressed"
598
+ const row = document.createElement("div")
599
+ row.style.borderTop = "1px solid #333"
600
+ row.style.padding = "6px 0"
601
+ if (isResolved) row.style.opacity = ".55"
602
+
603
+ const titleLine = document.createElement("div")
604
+ titleLine.textContent = c.component || "(unknown)"
605
+ if (c.selector) {
606
+ const code = document.createElement("code")
607
+ code.textContent = ` ${c.selector}`
608
+ titleLine.appendChild(code)
609
+ }
610
+ if (isResolved) {
611
+ const badge = document.createElement("span")
612
+ badge.textContent = " ✓ resolved"
613
+ badge.style.color = "#34d399"
614
+ badge.style.fontSize = "10px"
615
+ badge.style.marginLeft = "4px"
616
+ titleLine.appendChild(badge)
617
+ }
618
+ row.appendChild(titleLine)
619
+
620
+ const sourceLine = document.createElement("div")
621
+ sourceLine.style.opacity = ".7"
622
+ sourceLine.textContent = c.source || ""
623
+ row.appendChild(sourceLine)
624
+
625
+ const pathLine = document.createElement("div")
626
+ pathLine.style.opacity = ".6"
627
+ pathLine.style.fontSize = "11px"
628
+ pathLine.textContent = c.page_path || ""
629
+ row.appendChild(pathLine)
630
+
631
+ const commentLine = document.createElement("div")
632
+ commentLine.textContent = c.comment
633
+ if (isResolved) commentLine.style.textDecoration = "line-through"
634
+ row.appendChild(commentLine)
635
+
636
+ const actions = document.createElement("div")
637
+ actions.style.marginTop = "4px"
638
+ const del = document.createElement("a")
639
+ del.href = "#"
640
+ del.textContent = "Delete"
641
+ del.style.color = "#f97316"
642
+ del.style.fontSize = "11px"
643
+ del.addEventListener("click", (ev) => {
644
+ ev.preventDefault()
645
+ this._deleteComment(c.id)
646
+ })
647
+ actions.appendChild(del)
648
+ row.appendChild(actions)
649
+
650
+ this.panelTarget.appendChild(row)
651
+ })
652
+
653
+ const refreshBtn = document.createElement("button")
654
+ refreshBtn.type = "button"
655
+ refreshBtn.textContent = "Refresh"
656
+ refreshBtn.style.marginTop = "8px"
657
+ refreshBtn.addEventListener("click", () => this.refresh())
658
+ this.panelTarget.appendChild(refreshBtn)
659
+ }
660
+
661
+ _fetchServerComments() {
662
+ return fetch(INDEX_URL, { headers: { "Accept": "application/json" } })
663
+ .then((r) => r.json())
664
+ .then((j) => {
665
+ this.serverComments = Array.isArray(j.annotations) ? j.annotations : []
666
+ })
667
+ .catch(() => {
668
+ this.serverComments = []
669
+ })
670
+ }
671
+
672
+ _deleteComment(id) {
673
+ if (!id) return
674
+ fetch(DELETE_URL(id), { method: "DELETE", headers: { "Accept": "application/json" } })
675
+ .then(() => this._fetchServerComments())
676
+ .then(() => { this._renderPanel(); this._renderMarkers() })
677
+ .catch((err) => alert(`Delete failed: ${err.message || err}`))
678
+ }
679
+
680
+ _markersContainer() {
681
+ if (this._markersEl && document.body.contains(this._markersEl)) return this._markersEl
682
+ const el = document.createElement("div")
683
+ el.className = "pinmark-markers"
684
+ document.body.appendChild(el)
685
+ this._markersEl = el
686
+ return el
687
+ }
688
+
689
+ _clearMarkers() {
690
+ if (this._markersEl) this._markersEl.innerHTML = ""
691
+ }
692
+
693
+ _renderMarkers() {
694
+ const container = this._markersContainer()
695
+ container.innerHTML = ""
696
+
697
+ const path = window.location.pathname
698
+ const onPage = (this.serverComments || []).filter((c) => (c.page_path || null) === path)
699
+
700
+ onPage.forEach((c, idx) => {
701
+ let root
702
+ if (c.node_id) {
703
+ root = document.querySelector(`[data-pinmark-id="${CSS.escape(c.node_id)}"]`)
704
+ if (!root) return
705
+ } else {
706
+ // Page-level annotation: selector is relative to <body>.
707
+ root = document.body
708
+ }
709
+ let target = root
710
+ if (c.selector) {
711
+ try { target = root.querySelector(c.selector) || root } catch (_) { /* invalid selector */ }
712
+ }
713
+ if (target === document.body) return // nothing to pin to
714
+ const r = target.getBoundingClientRect()
715
+ if (r.width === 0 && r.height === 0) return // detached / hidden
716
+
717
+ const isResolved = c.status === "addressed"
718
+ const marker = document.createElement("button")
719
+ marker.type = "button"
720
+ marker.className = "pinmark-marker" + (isResolved ? " is-resolved" : "")
721
+ marker.textContent = isResolved ? "✓" : String(idx + 1)
722
+ marker.title = `${c.component || ""} — ${c.comment || ""}`.trim()
723
+ marker.style.left = `${r.left + window.scrollX}px`
724
+ marker.style.top = `${r.top + window.scrollY}px`
725
+ marker.addEventListener("click", (ev) => {
726
+ ev.preventDefault()
727
+ ev.stopPropagation()
728
+ target.scrollIntoView({ behavior: "smooth", block: "center" })
729
+ // Briefly highlight target
730
+ target.classList.add("pinmark-highlight")
731
+ setTimeout(() => target.classList.remove("pinmark-highlight"), 1200)
732
+ // Open the popover so the user can re-read / edit / delete
733
+ this._currentHighlight = target
734
+ this._openPopover(r.left, r.top, c.node_id, c.selector || null)
735
+ })
736
+ container.appendChild(marker)
737
+ })
738
+ }
739
+
740
+ _readBootstrapTree() {
741
+ const el = document.getElementById("pinmark-tree")
742
+ if (!el) return []
743
+ try {
744
+ return JSON.parse(el.textContent)
745
+ } catch {
746
+ return []
747
+ }
748
+ }
749
+
750
+ _indexTree(nodes, acc = {}) {
751
+ for (const node of nodes) {
752
+ acc[node.id] = node
753
+ this._indexTree(node.children || [], acc)
754
+ }
755
+ return acc
756
+ }
757
+
758
+ _enrichDom() {
759
+ const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_COMMENT)
760
+ const open = []
761
+ while (walker.nextNode()) {
762
+ const text = walker.currentNode.nodeValue.trim()
763
+ const begin = text.match(/^pinmark:begin id="([^"]+)"/)
764
+ const end = text.match(/^pinmark:end id="([^"]+)"/)
765
+ if (begin) {
766
+ open.push({ id: begin[1], commentNode: walker.currentNode })
767
+ } else if (end) {
768
+ const top = open.pop()
769
+ if (!top) continue
770
+ if (top.id !== end[1]) {
771
+ console.warn(`[pinmark] marker mismatch: begin=${top.id} end=${end[1]}`)
772
+ continue
773
+ }
774
+ let sibling = top.commentNode.nextSibling
775
+ while (sibling && sibling !== walker.currentNode) {
776
+ if (sibling.nodeType === 1) {
777
+ if (!sibling.hasAttribute("data-pinmark-id")) {
778
+ sibling.setAttribute("data-pinmark-id", top.id)
779
+ } else {
780
+ const existing = sibling.getAttribute("data-pinmark-ids") || sibling.getAttribute("data-pinmark-id")
781
+ const ids = new Set(existing.split(/\s+/))
782
+ ids.add(top.id)
783
+ sibling.setAttribute("data-pinmark-ids", [...ids].join(" "))
784
+ }
785
+ }
786
+ sibling = sibling.nextSibling
787
+ }
788
+ }
789
+ }
790
+ }
791
+
792
+ _domPathRelativeTo(root, target) {
793
+ const parts = []
794
+ let el = target
795
+ while (el && el !== root) {
796
+ const parent = el.parentElement
797
+ if (!parent) break
798
+
799
+ const tag = el.tagName.toLowerCase()
800
+ let segment = tag
801
+
802
+ if (el.id) {
803
+ segment += `#${this._escapeIdent(el.id)}`
804
+ }
805
+
806
+ const classes = this._meaningfulClasses(el)
807
+ if (classes.length > 0) {
808
+ segment += `.${classes.map((c) => this._escapeIdent(c)).join(".")}`
809
+ }
810
+
811
+ const sameTagSiblings = Array.from(parent.children).filter(
812
+ (c) => c.tagName === el.tagName
813
+ )
814
+ if (sameTagSiblings.length > 1) {
815
+ const index = sameTagSiblings.indexOf(el) + 1
816
+ segment += `:nth-of-type(${index})`
817
+ }
818
+
819
+ parts.unshift(segment)
820
+ el = parent
821
+ }
822
+ return parts.join(" > ")
823
+ }
824
+
825
+ _meaningfulClasses(el) {
826
+ if (!el.classList || el.classList.length === 0) return []
827
+ const skipExact = new Set([
828
+ "pinmark-highlight",
829
+ "pinmark-highlight-tag",
830
+ ])
831
+ return Array.from(el.classList).filter(
832
+ (c) => !skipExact.has(c) && !c.startsWith("pinmark-")
833
+ )
834
+ }
835
+
836
+ _escapeIdent(value) {
837
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
838
+ return CSS.escape(value)
839
+ }
840
+ return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&")
841
+ }
842
+
843
+ _textExcerpt(el) {
844
+ if (!el) return null
845
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim()
846
+ if (text.length === 0) return null
847
+ return text.length > 120 ? `${text.slice(0, 117)}…` : text
848
+ }
849
+
850
+ _setCookie(name, value, days = 30) {
851
+ const expires =
852
+ days < 0
853
+ ? "expires=Thu, 01 Jan 1970 00:00:00 GMT"
854
+ : `expires=${new Date(Date.now() + days * 864e5).toUTCString()}`
855
+ document.cookie = `${name}=${value}; path=/; ${expires}`
856
+ }
857
+ }