rails-markup 1.4.3 → 1.4.4

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,350 @@
1
+ Object.assign(RailsMarkupToolbar, {
2
+ _bindEvents() {
3
+ const self = this;
4
+ document.getElementById("rm-fab").addEventListener("click", () => self.toggleMode());
5
+ document.getElementById("rm-panel-toggle").addEventListener("click", () => self.togglePanel());
6
+ document.getElementById("rm-panel-close").addEventListener("click", () => self.togglePanel());
7
+ document.getElementById("rm-btn-cancel").addEventListener("click", () => self._closePopup());
8
+ document.getElementById("rm-btn-submit").addEventListener("click", (e) => self.submitAnnotation(e));
9
+ document.getElementById("rm-popup-input").addEventListener("input", () => self._updateCharCount());
10
+ document.getElementById("rm-filter-chips").addEventListener("click", (e) => {
11
+ const chip = e.target.closest("[data-filter]");
12
+ if (chip) self._filterAnnotations(chip.dataset.filter);
13
+ });
14
+
15
+ // Custom menus (intent/severity/status) — button+menu, never native selects
16
+ // so host FormSelect/Select2 enhancers cannot rewrite our DOM (#4).
17
+ this.root.addEventListener("click", (e) => {
18
+ const option = e.target.closest(".rm-menu-option");
19
+ if (option) {
20
+ e.preventDefault();
21
+ e.stopPropagation();
22
+ this._selectMenuOption(option);
23
+ return;
24
+ }
25
+ const btn = e.target.closest(".rm-menu-btn");
26
+ if (btn) {
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+ const menu = this._menuForElement(btn);
30
+ if (!menu || !this.root.contains(menu)) return;
31
+ this._toggleMenu(menu);
32
+ return;
33
+ }
34
+ if (!this._menuForElement(e.target)) this._closeAllMenus();
35
+ });
36
+ if (!this._boundMenuDocClick) {
37
+ this._boundMenuDocClick = (e) => {
38
+ if (!this.root || this._menuForElement(e.target)) return;
39
+ this._closeAllMenus();
40
+ };
41
+ document.addEventListener("click", this._boundMenuDocClick);
42
+ }
43
+
44
+ // Event delegation for cards (status change, edit, delete, or click scrolls to element)
45
+ const panelList = document.getElementById("rm-panel-list");
46
+ if (!this._boundMenuViewportChange) {
47
+ this._boundMenuViewportChange = () => self._closeAllMenus();
48
+ window.addEventListener("resize", this._boundMenuViewportChange);
49
+ window.addEventListener("scroll", this._boundMenuViewportChange, { passive: true });
50
+ }
51
+ panelList.addEventListener("scroll", this._boundMenuViewportChange, { passive: true });
52
+ panelList.addEventListener("click", (e) => {
53
+ const retryBtn = e.target.closest("[data-retry-client-id]");
54
+ if (retryBtn) {
55
+ e.stopPropagation();
56
+ self._retrySync(retryBtn.dataset.retryClientId);
57
+ return;
58
+ }
59
+ // Status menu — leave bubbling for the root menu handler; don't scroll.
60
+ if (e.target.closest(".rm-menu")) return;
61
+ // Edit button
62
+ const editBtn = e.target.closest("[data-edit-id]");
63
+ if (editBtn) {
64
+ e.stopPropagation();
65
+ const id = parseInt(editBtn.dataset.editId, 10);
66
+ self._editAnnotation(id);
67
+ return;
68
+ }
69
+ // Delete button
70
+ const deleteBtn = e.target.closest("[data-delete-id]");
71
+ if (deleteBtn) {
72
+ e.stopPropagation();
73
+ const id = parseInt(deleteBtn.dataset.deleteId, 10);
74
+ self._deleteAnnotation(id);
75
+ return;
76
+ }
77
+ // Card click scrolls to element
78
+ const card = e.target.closest("[data-card-id]");
79
+ if (!card) return;
80
+ const id = parseInt(card.dataset.cardId, 10);
81
+ const annotation = self.annotations.find(a => a.id === id);
82
+ if (!annotation) return;
83
+ const el = self._findElement(annotation);
84
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
85
+ });
86
+
87
+ // Event delegation for pins (click opens panel + scrolls to card)
88
+ document.getElementById("rm-pins-container").addEventListener("click", (e) => {
89
+ const pin = e.target.closest("[data-pin-id]");
90
+ if (!pin) return;
91
+ const panel = document.getElementById("rm-panel");
92
+ if (panel.style.display !== "flex") self.togglePanel();
93
+ const card = document.querySelector('[data-card-id="' + pin.dataset.pinId + '"]');
94
+ if (card) card.scrollIntoView({ behavior: "smooth", block: "center" });
95
+ });
96
+
97
+ this._boundMouseMove = (e) => self._handleMouseMove(e);
98
+ this._boundMouseDown = (e) => self._handleMouseDown(e);
99
+ this._boundMouseUp = (e) => self._handleMouseUp(e);
100
+ this._boundClick = (e) => self._handleClick(e);
101
+ if (!this._boundKeyDown) {
102
+ this._boundKeyDown = (e) => self._handleKeyDown(e);
103
+ document.addEventListener("keydown", this._boundKeyDown, true);
104
+ }
105
+ this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el) && e.cancelable) e.preventDefault(); self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
106
+ this._boundTouchEnd = (e) => { if (self.active && e.changedTouches[0]) { const t = e.changedTouches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el)) { e.preventDefault(); self._handleMouseUp({ clientX: t.clientX, clientY: t.clientY, preventDefault(){}, stopPropagation(){} }); } } };
107
+
108
+ // Turbo Frames — partial DOM update, reposition pins
109
+ if (!this._boundTurboFrame) {
110
+ this._boundTurboFrame = () => self._onTurboFrameRender();
111
+ document.addEventListener("turbo:frame-render", this._boundTurboFrame);
112
+ }
113
+ },
114
+ _onTurboNavigate() {
115
+ const newPageUrl = this._pageUrl();
116
+ if (newPageUrl === this._currentPageUrl) return; // same page (anchor change, etc.)
117
+ this._currentPathname = window.location.pathname;
118
+ this._currentPageUrl = newPageUrl;
119
+
120
+ // Deactivate crosshair mode
121
+ this._deactivateMode();
122
+
123
+ // Close popup
124
+ const popup = document.getElementById("rm-popup");
125
+ if (popup && popup.style.display === "block") this._closePopup();
126
+
127
+ // Close panel
128
+ const panel = document.getElementById("rm-panel");
129
+ if (panel) panel.style.display = "none";
130
+
131
+ // Rerender pins for current page (annotations are global, pins are page-specific)
132
+ this._renderPins();
133
+ this._updateCount();
134
+ this._rebuildList();
135
+
136
+ // Re-init session for new URL
137
+ this._pullNeeded = true;
138
+ if (this.serverOnline) {
139
+ this._synchronizeCurrentPage(true).catch(error => console.warn("[rails-markup] page sync failed:", error));
140
+ }
141
+ },
142
+ _onTurboFrameRender() {
143
+ // Frame content changed — DOM elements may have moved, reposition pins
144
+ this._repositionPins();
145
+ },
146
+ toggleMode() {
147
+ this.active = !this.active;
148
+ if (this.active) {
149
+ this._activateMode();
150
+ // Always open panel when activating annotation mode
151
+ if (document.getElementById("rm-panel").style.display !== "flex") {
152
+ this.togglePanel();
153
+ }
154
+ } else {
155
+ this._deactivateMode();
156
+ }
157
+ },
158
+ _activateMode() {
159
+ document.body.style.cursor = "crosshair";
160
+ const fab = document.getElementById("rm-fab");
161
+ const iconSize = this._fabIconSize();
162
+ fab.style.transform = "scale(0.9)";
163
+ fab.style.boxShadow = `0 0 0 3px ${this._accentBg()}, 0 0 0 6px rgba(99,102,241,0.2)`;
164
+ fab.innerHTML = `<svg viewBox="0 0 24 24" style="width:${iconSize}px;height:${iconSize}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M6 18L18 6M6 6l12 12"/></svg><span class="rm-fab-badge" id="rm-fab-badge">${document.getElementById("rm-fab-badge")?.textContent || ""}</span>`;
165
+ document.addEventListener("mousemove", this._boundMouseMove, true);
166
+ document.addEventListener("mousedown", this._boundMouseDown, true);
167
+ document.addEventListener("mouseup", this._boundMouseUp, true);
168
+ document.addEventListener("click", this._boundClick, true);
169
+ document.addEventListener("touchstart", this._boundTouchStart, true);
170
+ document.addEventListener("touchend", this._boundTouchEnd, true);
171
+ },
172
+ _deactivateMode() {
173
+ this.active = false;
174
+ document.body.style.cursor = "";
175
+ const fab = document.getElementById("rm-fab");
176
+ if (fab) {
177
+ const iconSize = this._fabIconSize();
178
+ fab.style.transform = "";
179
+ fab.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
180
+ fab.innerHTML = `<svg viewBox="0 0 24 24" style="width:${iconSize}px;height:${iconSize}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg><span class="rm-fab-badge" id="rm-fab-badge">${this.annotations.length || ""}</span>`;
181
+ this._updateCount();
182
+ }
183
+ document.removeEventListener("mousemove", this._boundMouseMove, true);
184
+ document.removeEventListener("mousedown", this._boundMouseDown, true);
185
+ document.removeEventListener("mouseup", this._boundMouseUp, true);
186
+ document.removeEventListener("click", this._boundClick, true);
187
+ document.removeEventListener("touchstart", this._boundTouchStart, true);
188
+ document.removeEventListener("touchend", this._boundTouchEnd, true);
189
+ this._removeHighlight();
190
+ },
191
+ togglePanel() {
192
+ const panel = document.getElementById("rm-panel");
193
+ const fab = document.getElementById("rm-fab");
194
+ if (panel.style.display === "flex") {
195
+ panel.style.display = "none";
196
+ if (fab) fab.setAttribute("aria-expanded", "false");
197
+ } else {
198
+ panel.style.display = "flex";
199
+ if (fab) fab.setAttribute("aria-expanded", "true");
200
+ }
201
+ },
202
+ _handleMouseMove(event) {
203
+ if (!this.active) return;
204
+ const el = document.elementFromPoint(event.clientX, event.clientY);
205
+ if (!el || this._isToolbar(el)) { this._removeHighlight(); return; }
206
+ if (el === this.hoveredElement) return;
207
+ this._removeHighlight();
208
+ this.hoveredElement = el;
209
+ el.dataset.rmOrigOutline = el.style.outline || "";
210
+ el.style.outline = `2px solid ${this._accentBg()}`;
211
+ el.style.outlineOffset = "2px";
212
+ },
213
+ _handleMouseDown(event) {
214
+ const el = document.elementFromPoint(event.clientX, event.clientY);
215
+ if (el && !this._isToolbar(el)) {
216
+ this.clickedElement = el;
217
+ // Suppress the press so host controls (buttons, drag handles, form
218
+ // fields) don't act before mouseup/click is blocked. Guard for the
219
+ // synthetic object passed from the touchstart handler.
220
+ if (typeof event.preventDefault === "function") event.preventDefault();
221
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
222
+ }
223
+ },
224
+ async _handleMouseUp(event) {
225
+ if (!this.active) return;
226
+ const el = this.clickedElement || document.elementFromPoint(event.clientX, event.clientY);
227
+ if (!el || this._isToolbar(el)) return;
228
+ event.preventDefault();
229
+ event.stopPropagation();
230
+ const sel = window.getSelection();
231
+ this.selectedText = (sel && sel.toString().trim().length > 0) ? sel.toString().trim() : null;
232
+ this._currentElement = this._identify(el);
233
+ this._currentScreenshot = null;
234
+ if (this.enableScreenshots) {
235
+ this._currentScreenshot = await this._captureElement(el);
236
+ }
237
+ this._showPopup(event.clientX, event.clientY);
238
+ this.clickedElement = null;
239
+ },
240
+ _handleClick(event) {
241
+ if (!this.active) return;
242
+ const el = event.target;
243
+ if (this._isToolbar(el)) return;
244
+ // Block link navigation and Turbo visits while annotating
245
+ event.preventDefault();
246
+ event.stopPropagation();
247
+ },
248
+ _handleKeyDown(event) {
249
+ const openList = this.root?.querySelector(".rm-menu-list.rm-menu-open");
250
+ const openMenu = this._menuForElement(openList);
251
+ if (openMenu) {
252
+ const options = Array.from(openList.querySelectorAll(".rm-menu-option"));
253
+ const focusedIndex = options.indexOf(document.activeElement);
254
+
255
+ if (event.key === "Escape") {
256
+ this._closeMenu(openMenu, true);
257
+ event.preventDefault();
258
+ event.stopPropagation();
259
+ return;
260
+ }
261
+ if (event.key === "Tab") {
262
+ this._closeMenu(openMenu);
263
+ return;
264
+ }
265
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
266
+ const direction = event.key === "ArrowDown" ? 1 : -1;
267
+ const start = focusedIndex === -1 ? 0 : focusedIndex;
268
+ const next = (start + direction + options.length) % options.length;
269
+ options[next]?.focus();
270
+ event.preventDefault();
271
+ event.stopPropagation();
272
+ return;
273
+ }
274
+ if (event.key === "Home" || event.key === "End") {
275
+ const option = event.key === "Home" ? options[0] : options[options.length - 1];
276
+ option?.focus();
277
+ event.preventDefault();
278
+ event.stopPropagation();
279
+ return;
280
+ }
281
+ if ((event.key === "Enter" || event.key === " ") && focusedIndex !== -1) {
282
+ this._selectMenuOption(options[focusedIndex]);
283
+ event.preventDefault();
284
+ event.stopPropagation();
285
+ return;
286
+ }
287
+ }
288
+
289
+ const trigger = event.target.closest?.(".rm-menu-btn");
290
+ if (trigger && this.root?.contains(trigger) &&
291
+ ["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) {
292
+ const menu = this._menuForElement(trigger);
293
+ if (menu) this._openMenu(menu);
294
+ event.preventDefault();
295
+ event.stopPropagation();
296
+ return;
297
+ }
298
+
299
+ if (event.key === "Escape") {
300
+ const popup = document.getElementById("rm-popup");
301
+ if (popup && popup.style.display === "block") {
302
+ this._closePopup();
303
+ event.preventDefault();
304
+ event.stopPropagation();
305
+ return;
306
+ }
307
+ if (this.active) {
308
+ this._deactivateMode();
309
+ event.preventDefault();
310
+ event.stopPropagation();
311
+ return;
312
+ }
313
+ }
314
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
315
+ const popup = document.getElementById("rm-popup");
316
+ if (popup && popup.style.display === "block") {
317
+ this.submitAnnotation();
318
+ }
319
+ }
320
+ },
321
+ _identify(el) {
322
+ const tag = el.tagName.toLowerCase();
323
+ const id = el.id ? "#" + el.id : "";
324
+ const cls = Array.from(el.classList).filter(c => !c.startsWith("rm-")).slice(0, 5).map(c => "." + c).join("");
325
+ const text = (el.textContent || "").trim().slice(0, 80);
326
+ const rect = el.getBoundingClientRect();
327
+ return {
328
+ selector: tag + id + cls,
329
+ cssPath: this._cssPath(el),
330
+ nearbyText: text,
331
+ boundingBox: { top: Math.round(rect.top + window.scrollY), left: Math.round(rect.left + window.scrollX), width: Math.round(rect.width), height: Math.round(rect.height) }
332
+ };
333
+ },
334
+ _cssPath(el) {
335
+ const parts = [];
336
+ let cur = el;
337
+ while (cur && cur !== document.body && parts.length < 5) {
338
+ let sel = cur.tagName.toLowerCase();
339
+ if (cur.id) { sel += "#" + cur.id; parts.unshift(sel); break; }
340
+ const parent = cur.parentElement;
341
+ if (parent) {
342
+ const sibs = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
343
+ if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(cur) + 1) + ")";
344
+ }
345
+ parts.unshift(sel);
346
+ cur = cur.parentElement;
347
+ }
348
+ return parts.join(" > ");
349
+ },
350
+ });