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.
@@ -1,2779 +0,0 @@
1
- /**
2
- * Rails Markup Toolbar — self-contained annotation UI
3
- * No dependencies (no Stimulus, no importmap). Works in any Rails app.
4
- *
5
- * Usage: include via <script> tag, then call:
6
- * RailsMarkupToolbar.init({ endpoint: "/feedback/api", accent: "indigo" })
7
- */
8
- (function(global) {
9
- "use strict";
10
-
11
- if (global.RailsMarkupToolbar) return;
12
-
13
- const RailsMarkupToolbar = {
14
- // State
15
- annotations: [],
16
- outbox: {},
17
- legacyMigrations: {},
18
- nextId: 1,
19
- active: false,
20
- serverOnline: false,
21
- sessionId: null,
22
- sseSource: null,
23
- healthInterval: null,
24
- hoveredElement: null,
25
- selectedText: null,
26
- clickedElement: null,
27
- activeFilter: "all",
28
- editingId: null,
29
- _currentScreenshot: null,
30
- _storageError: null,
31
- _outboxFlushScheduled: false,
32
- _outboxFlushTimer: null,
33
- _flushPromise: null,
34
- _flushAfterPullPromise: null,
35
- _healthPromise: null,
36
- _pullPromise: null,
37
- _pullPageUrl: null,
38
- _pullNeeded: true,
39
- _syncRetryTimer: null,
40
- _syncRetryAttempt: 0,
41
- _syncRetryDelay: 0,
42
- _syncBaseRetryDelay: 1000,
43
- _syncMaxRetryDelay: 30000,
44
- _syncMalformedLimit: 3,
45
- _syncUnavailable: null,
46
- legacyStorageEndpoint: null,
47
-
48
- // Drawing state
49
- drawingMode: null, // null | "arrow" | "rect" | "highlight"
50
- drawingCanvas: null,
51
- drawingCtx: null,
52
- drawingStart: null,
53
- drawingHistory: [],
54
- _screenshotImg: null,
55
-
56
- // Config
57
- endpoint: "/feedback/api",
58
- accent: "indigo",
59
- position: "bl",
60
- size: "default",
61
- fabVisible: true,
62
- enableScreenshots: true,
63
-
64
- // DOM refs (set in init)
65
- root: null,
66
-
67
- init(opts = {}) {
68
- const previousPathname = this._currentPathname;
69
- const previousPageUrl = this._currentPageUrl;
70
- const currentPageUrl = this._pageUrl();
71
- this.endpoint = opts.endpoint || "/feedback/api";
72
- this.accent = opts.accent || "indigo";
73
- this.position = opts.position || "bl";
74
- this.size = opts.size || "default";
75
- this.fabVisible = opts.fabVisible !== false;
76
- this.enableScreenshots = opts.enableScreenshots !== false;
77
- this.legacyStorageEndpoint = opts.legacyStorageEndpoint || this.legacyStorageEndpoint;
78
- this.healthIntervalMs = (opts.healthInterval || 60) * 1000;
79
-
80
- if (document.getElementById("rm-toolbar-root")) {
81
- if (currentPageUrl !== this._currentPageUrl) this._onTurboNavigate();
82
- return;
83
- }
84
-
85
- if (previousPathname && previousPathname !== window.location.pathname) this._deactivateMode();
86
- if (previousPageUrl && previousPageUrl !== currentPageUrl && previousPathname === window.location.pathname) this._deactivateMode();
87
- this._currentPathname = window.location.pathname;
88
- this._currentPageUrl = currentPageUrl;
89
- this._injectStyles();
90
- this._injectDOM();
91
- this._bindEvents();
92
- this._loadFromStorage();
93
- this._checkHealth();
94
- if (!this.healthInterval) this.healthInterval = setInterval(() => this._checkHealth(), this.healthIntervalMs);
95
- if (!this._boundVisibilityChange) {
96
- this._boundVisibilityChange = () => this._onVisibilityChange();
97
- document.addEventListener("visibilitychange", this._boundVisibilityChange);
98
- }
99
- if (!this._boundOnline) {
100
- this._boundOnline = () => this._onOnline();
101
- window.addEventListener("online", this._boundOnline);
102
- }
103
- this._renderPins();
104
- this._updateCount();
105
- if (previousPageUrl && previousPageUrl !== this._currentPageUrl && this.serverOnline) this._initSession();
106
- },
107
-
108
- destroy() {
109
- this._closeAllMenus();
110
- this._deactivateMode();
111
- if (this.sseSource) { this.sseSource.close(); this.sseSource = null; }
112
- if (this.healthInterval) { clearInterval(this.healthInterval); this.healthInterval = null; }
113
- if (this._outboxFlushTimer) { clearTimeout(this._outboxFlushTimer); this._outboxFlushTimer = null; }
114
- if (this._syncRetryTimer) { clearTimeout(this._syncRetryTimer); this._syncRetryTimer = null; }
115
- this._outboxFlushScheduled = false;
116
- if (this._boundVisibilityChange) {
117
- document.removeEventListener("visibilitychange", this._boundVisibilityChange);
118
- this._boundVisibilityChange = null;
119
- }
120
- if (this._boundOnline) {
121
- window.removeEventListener("online", this._boundOnline);
122
- this._boundOnline = null;
123
- }
124
- if (this._boundKeyDown) {
125
- document.removeEventListener("keydown", this._boundKeyDown, true);
126
- this._boundKeyDown = null;
127
- }
128
- if (this._onResize) window.removeEventListener("resize", this._onResize);
129
- if (this._onScroll) window.removeEventListener("scroll", this._onScroll);
130
- if (this._boundTurboFrame) {
131
- document.removeEventListener("turbo:frame-render", this._boundTurboFrame);
132
- this._boundTurboFrame = null;
133
- }
134
- if (this._boundMenuDocClick) {
135
- document.removeEventListener("click", this._boundMenuDocClick);
136
- this._boundMenuDocClick = null;
137
- }
138
- if (this._boundMenuViewportChange) {
139
- document.getElementById("rm-panel-list")
140
- ?.removeEventListener("scroll", this._boundMenuViewportChange);
141
- window.removeEventListener("resize", this._boundMenuViewportChange);
142
- window.removeEventListener("scroll", this._boundMenuViewportChange);
143
- this._boundMenuViewportChange = null;
144
- }
145
- this._onResize = null;
146
- this._onScroll = null;
147
- const root = document.getElementById("rm-toolbar-root");
148
- if (root) root.remove();
149
- const pins = document.getElementById("rm-pins-container");
150
- if (pins) pins.remove();
151
- const styles = document.getElementById("rm-toolbar-styles");
152
- if (styles) styles.remove();
153
- },
154
-
155
- // ---- DOM injection ----
156
-
157
- _injectStyles() {
158
- if (document.getElementById("rm-toolbar-styles")) return;
159
- const style = document.createElement("style");
160
- style.id = "rm-toolbar-styles";
161
- style.textContent = `
162
- @keyframes rm-pulse { 0%,100%{box-shadow:0 2px 8px rgba(0,0,0,0.2)} 50%{box-shadow:0 2px 12px rgba(0,0,0,0.3),0 0 0 4px rgba(99,102,241,0.15)} }
163
- @keyframes rm-toast-in { from{opacity:0;transform:translateY(16px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
164
- @keyframes rm-toast-out { from{opacity:1;transform:translateY(0) scale(1)} to{opacity:0;transform:translateY(16px) scale(0.95)} }
165
- #rm-toolbar-root { position:fixed; inset:0; pointer-events:none; z-index:9979; }
166
- #rm-toolbar-root * { box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; }
167
- .rm-fab, .rm-panel-toggle, .rm-panel, .rm-popup { pointer-events:auto; }
168
- .rm-fab { position:fixed; z-index:9980; border-radius:50%; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all 0.2s; box-shadow:0 4px 12px rgba(0,0,0,0.15); }
169
- .rm-fab svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
170
- .rm-fab-badge { position:absolute; top:-4px; right:-4px; min-width:20px; height:20px; padding:0 4px; border-radius:10px; background:#ef4444; color:#fff; font-size:11px; font-weight:700; display:none; align-items:center; justify-content:center; }
171
- .rm-panel-toggle { position:fixed; z-index:9980; width:32px; height:32px; border-radius:50%; border:1px solid #e5e7eb; background:rgba(255,255,255,0.9); cursor:pointer; display:none; align-items:center; justify-content:center; color:#6b7280; transition:all 0.2s; backdrop-filter:blur(8px); }
172
- .rm-panel-toggle:hover { color:#4361ee; }
173
- .rm-panel-toggle svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
174
- .rm-toast-container { position:fixed; z-index:9983; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
175
- .rm-pins-container { position:absolute; top:0; left:0; width:100%; z-index:9979; pointer-events:none; }
176
- .rm-pin { pointer-events:auto; }
177
- .rm-popup { display:none; position:fixed; z-index:9982; width:360px; max-width:calc(100vw - 24px); background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); padding:16px; }
178
- #rm-toolbar-root .rm-popup textarea { display:block; width:100%; max-width:100%; font-size:13px; font-weight:400; line-height:1.4; color:#1f2937; height:auto; margin:0; border:1px solid #e5e7eb; border-radius:12px; padding:12px; resize:none; outline:none; font-family:inherit; background:#fff; background-image:none; box-shadow:none; appearance:none; -webkit-appearance:none; transition:border-color 0.15s,box-shadow 0.15s; }
179
- #rm-toolbar-root .rm-popup textarea:focus { border:1px solid #818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
180
- #rm-toolbar-root .rm-menu { position:relative; display:inline-block; vertical-align:middle; }
181
- #rm-toolbar-root .rm-menu-btn { display:inline-flex; align-items:center; gap:4px; width:auto; height:auto; margin:0; font-size:11px; font-weight:500; line-height:1.4; color:#374151; border:1px solid #e5e7eb; border-radius:8px; padding:6px 8px; background:#fff; background-image:none; box-shadow:none; outline:none; text-transform:none; appearance:none; -webkit-appearance:none; cursor:pointer; }
182
- #rm-toolbar-root .rm-menu-btn:hover { border-color:#d1d5db; background:#fff; }
183
- #rm-toolbar-root .rm-menu-btn:focus { outline:none; border-color:#818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
184
- #rm-toolbar-root .rm-menu-btn[aria-expanded="true"] { border-color:#818cf8; }
185
- #rm-toolbar-root .rm-menu-chevron { width:10px; height:10px; flex-shrink:0; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; opacity:0.55; }
186
- #rm-toolbar-root .rm-menu-list { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:9984; min-width:100%; padding:4px; margin:0; list-style:none; background:#fff; border:1px solid #e5e7eb; border-radius:10px; box-shadow:0 10px 24px rgba(0,0,0,0.12); }
187
- #rm-toolbar-root .rm-menu-list.rm-menu-open { display:block; }
188
- #rm-toolbar-root .rm-menu-option { display:block; width:100%; margin:0; padding:6px 10px; font-size:11px; font-weight:500; line-height:1.4; color:#374151; text-align:left; border:none; border-radius:6px; background:transparent; background-image:none; box-shadow:none; cursor:pointer; appearance:none; -webkit-appearance:none; }
189
- #rm-toolbar-root .rm-menu-option:hover, #rm-toolbar-root .rm-menu-option:focus { background:#f3f4f6; outline:none; }
190
- #rm-toolbar-root .rm-menu-option-active { background:#eef2ff; color:#4338ca; }
191
- #rm-toolbar-root .rm-menu-compact .rm-menu-btn { font-size:10px; padding:2px 6px; border-radius:4px; color:#6b7280; }
192
- #rm-toolbar-root .rm-menu-compact .rm-menu-list, #rm-toolbar-root .rm-menu-list-compact { min-width:120px; right:0; left:auto; }
193
- #rm-toolbar-root .rm-menu-compact .rm-menu-option, #rm-toolbar-root .rm-menu-list-compact .rm-menu-option { font-size:10px; padding:5px 8px; }
194
- .rm-popup-el { font-size:11px; color:#9ca3af; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; line-height:1.4; }
195
- .rm-popup-text { font-size:12px; color:#6b7280; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; line-height:1.4; }
196
- .rm-popup-actions { display:flex; align-items:center; gap:8px; margin-top:8px; }
197
- .rm-popup-actions .rm-count { font-size:10px; color:#d1d5db; margin-left:auto; font-variant-numeric:tabular-nums; }
198
- #rm-toolbar-root .rm-btn-cancel { padding:6px 12px; font-size:12px; color:#9ca3af; background:none; background-image:none; border:none; box-shadow:none; cursor:pointer; border-radius:8px; appearance:none; -webkit-appearance:none; }
199
- #rm-toolbar-root .rm-btn-cancel:hover { color:#6b7280; }
200
- #rm-toolbar-root .rm-btn-submit { padding:6px 16px; font-size:12px; font-weight:500; color:#fff; border:none; border-radius:8px; cursor:pointer; display:inline-flex; align-items:center; gap:6px; box-shadow:none; appearance:none; -webkit-appearance:none; }
201
- #rm-toolbar-root .rm-btn-submit kbd { font-size:9px; opacity:0.6; font-family:sans-serif; }
202
- .rm-panel { display:none; position:fixed; z-index:9981; width:380px; max-width:calc(100vw - 48px); max-height:60vh; background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); flex-direction:column; }
203
- .rm-panel-header { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid #f3f4f6; }
204
- .rm-panel-header h3 { font-size:14px; font-weight:600; color:#1f2937; }
205
- .rm-panel-count { min-width:20px; text-align:center; padding:2px 6px; font-size:10px; font-weight:600; border-radius:10px; }
206
- .rm-panel-close { padding:6px; color:#d1d5db; background:none; border:none; cursor:pointer; border-radius:8px; }
207
- .rm-panel-close:hover { color:#6b7280; }
208
- .rm-panel-close svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
209
- .rm-filter-chips { display:flex; gap:6px; padding:8px 16px; border-bottom:1px solid #f9fafb; }
210
- .rm-chip { padding:4px 8px; font-size:10px; font-weight:500; border-radius:10px; cursor:pointer; transition:all 0.15s; border:none; }
211
- .rm-chip-active { color:#fff; }
212
- .rm-chip-inactive { background:#f9fafb; color:#9ca3af; }
213
- .rm-chip-inactive:hover { color:#6b7280; }
214
- .rm-panel-list { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
215
- .rm-panel-footer { display:flex; align-items:center; gap:8px; padding:10px 16px; border-top:1px solid #f3f4f6; font-size:11px; color:#9ca3af; }
216
- .rm-status-dot { width:8px; height:8px; border-radius:50%; background:#d1d5db; }
217
- .rm-card { padding:12px; background:#fff; border-radius:8px; border:1px solid #f3f4f6; border-left:3px solid; cursor:pointer; transition:all 0.15s; }
218
- .rm-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.05); }
219
- .rm-card-top { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
220
- .rm-card-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
221
- .rm-card-id { font-size:10px; font-weight:600; color:#9ca3af; }
222
- .rm-card-badge { padding:2px 6px; font-size:10px; font-weight:500; border-radius:10px; }
223
- .rm-card-body { margin-top:6px; font-size:13px; line-height:1.5; color:#1f2937; }
224
- .rm-card-path { margin-top:4px; font-size:10px; color:#d1d5db; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
225
- .rm-card-thread { margin-top:8px; padding:8px; background:rgba(249,250,251,0.8); border-radius:8px; font-size:12px; color:#6b7280; border-left:2px solid; }
226
- .rm-card-thread-role { font-size:10px; font-weight:500; color:#9ca3af; text-transform:uppercase; letter-spacing:0.05em; }
227
- .rm-empty { text-align:center; padding:32px 16px; color:#9ca3af; }
228
- .rm-empty-icon { font-size:32px; margin-bottom:8px; }
229
- .rm-empty-text { font-size:13px; }
230
- .rm-pin { position:absolute; display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; color:#fff; font-size:10px; font-weight:700; cursor:pointer; transition:transform 0.2s; z-index:9979; box-shadow:0 2px 8px rgba(0,0,0,0.2); }
231
- .rm-pin:hover { transform:scale(1.25); }
232
- .rm-pin-active { animation:rm-pulse 2s ease-in-out infinite; }
233
- .rm-toast { padding:8px 12px; border-radius:8px; border:1px solid; font-size:12px; font-weight:500; box-shadow:0 2px 8px rgba(0,0,0,0.05); animation:rm-toast-in 0.3s ease; }
234
- `;
235
- document.head.appendChild(style);
236
- },
237
-
238
- _injectDOM() {
239
- const root = document.createElement("div");
240
- root.id = "rm-toolbar-root";
241
-
242
- const accentBg = this._accentBg();
243
- const accentBgHover = this._accentBgHover();
244
- const accentLight = this._accentLight();
245
- const accentText = this._accentText();
246
-
247
- // Position: bl (bottom-left), br (bottom-right), tl (top-left), tr (top-right)
248
- const posMap = { bl: "bottom:24px;left:24px;", br: "bottom:24px;right:24px;", tl: "top:24px;left:24px;", tr: "top:24px;right:24px;" };
249
- const fabPos = posMap[this.position] || posMap.bl;
250
-
251
- // Size: default (48px), compact (40px), slim (32px)
252
- const sizeMap = { "default": { dim: 48, icon: 20 }, compact: { dim: 40, icon: 18 }, slim: { dim: 32, icon: 16 } };
253
- const fabSize = sizeMap[this.size] || sizeMap["default"];
254
-
255
- // Panel offset matches FAB position
256
- const isRight = this.position === "br" || this.position === "tr";
257
- const isTop = this.position === "tl" || this.position === "tr";
258
- const panelToggleStyle = isRight ? `${isTop ? 'top' : 'bottom'}:24px;right:${fabSize.dim + 8 + 24}px;` : `${isTop ? 'top' : 'bottom'}:24px;left:${fabSize.dim + 8 + 24}px;`;
259
- const panelStyle = isRight
260
- ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
261
- : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
262
- const toastStyle = isRight
263
- ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
264
- : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
265
-
266
- root.innerHTML = `
267
- <button class="rm-fab" id="rm-fab" style="${fabPos}width:${fabSize.dim}px;height:${fabSize.dim}px;background:${accentBg};color:#fff;${this.fabVisible ? "" : "display:none;"}" title="Toggle annotation mode" aria-label="Toggle annotation mode" aria-expanded="false" aria-controls="rm-panel">
268
- <svg viewBox="0 0 24 24" style="width:${fabSize.icon}px;height:${fabSize.icon}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>
269
- <span class="rm-fab-badge" id="rm-fab-badge"></span>
270
- </button>
271
- <button class="rm-panel-toggle" id="rm-panel-toggle" style="${panelToggleStyle}" title="View annotations" aria-label="View annotations" aria-controls="rm-panel">
272
- <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h7"/></svg>
273
- </button>
274
- <div class="rm-toast-container" id="rm-toast-container" style="${toastStyle}"></div>
275
- <div class="rm-popup" id="rm-popup" role="dialog" aria-label="Add annotation" aria-modal="false">
276
- <div style="margin-bottom:12px">
277
- <p class="rm-popup-el" id="rm-popup-el"></p>
278
- <p class="rm-popup-text" id="rm-popup-text"></p>
279
- </div>
280
- <textarea id="rm-popup-input" rows="3" placeholder="What should change?"></textarea>
281
- <div style="display:flex;align-items:center;gap:8px;margin-top:12px">
282
- ${this._menuMarkup({ inputId: "rm-intent-select", label: "Intent", value: "change", options: this._intentOptions() })}
283
- ${this._menuMarkup({ inputId: "rm-severity-select", label: "Severity", value: "suggestion", options: this._severityOptions() })}
284
- <span class="rm-count" id="rm-char-count"></span>
285
- </div>
286
- <div class="rm-popup-actions">
287
- <button class="rm-btn-cancel" id="rm-btn-cancel">Cancel</button>
288
- <button class="rm-btn-submit" id="rm-btn-submit" style="background:${accentBg}">
289
- <span id="rm-submit-label">Add</span>
290
- <kbd>⌘↩</kbd>
291
- </button>
292
- </div>
293
- </div>
294
- <div class="rm-panel" id="rm-panel" style="${panelStyle}" role="dialog" aria-label="Annotations panel">
295
- <div class="rm-panel-header">
296
- <div style="display:flex;align-items:center;gap:8px">
297
- <h3>Feedback</h3>
298
- <span class="rm-panel-count" id="rm-panel-count" style="background:${accentLight};color:${accentText}">0</span>
299
- </div>
300
- <button class="rm-panel-close" id="rm-panel-close" aria-label="Close annotations panel">
301
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18L18 6M6 6l12 12"/></svg>
302
- </button>
303
- </div>
304
- <div class="rm-filter-chips" id="rm-filter-chips">
305
- <button class="rm-chip rm-chip-active" data-filter="all" style="background:${accentBg}">All</button>
306
- <button class="rm-chip rm-chip-inactive" data-filter="pending">Pending</button>
307
- <button class="rm-chip rm-chip-inactive" data-filter="resolved">Resolved</button>
308
- </div>
309
- <div class="rm-panel-list" id="rm-panel-list"></div>
310
- <div class="rm-panel-footer">
311
- <span class="rm-status-dot" id="rm-status-dot"></span>
312
- <span id="rm-status-text">Offline</span>
313
- </div>
314
- </div>
315
- `;
316
-
317
- document.body.appendChild(root);
318
- this.root = root;
319
- // Pins container lives on body (not inside fixed root); scroll listener keeps them stuck to target elements
320
- const pinsContainer = document.createElement("div");
321
- pinsContainer.className = "rm-pins-container";
322
- pinsContainer.id = "rm-pins-container";
323
- document.body.appendChild(pinsContainer);
324
- if (!this._onResize) {
325
- this._onResize = this._debouncedRepositionPins(250);
326
- this._onScroll = this._debouncedRepositionPins(50);
327
- window.addEventListener("resize", this._onResize);
328
- window.addEventListener("scroll", this._onScroll, { passive: true });
329
- }
330
- },
331
-
332
- _bindEvents() {
333
- const self = this;
334
- document.getElementById("rm-fab").addEventListener("click", () => self.toggleMode());
335
- document.getElementById("rm-panel-toggle").addEventListener("click", () => self.togglePanel());
336
- document.getElementById("rm-panel-close").addEventListener("click", () => self.togglePanel());
337
- document.getElementById("rm-btn-cancel").addEventListener("click", () => self._closePopup());
338
- document.getElementById("rm-btn-submit").addEventListener("click", (e) => self.submitAnnotation(e));
339
- document.getElementById("rm-popup-input").addEventListener("input", () => self._updateCharCount());
340
- document.getElementById("rm-filter-chips").addEventListener("click", (e) => {
341
- const chip = e.target.closest("[data-filter]");
342
- if (chip) self._filterAnnotations(chip.dataset.filter);
343
- });
344
-
345
- // Custom menus (intent/severity/status) — button+menu, never native selects
346
- // so host FormSelect/Select2 enhancers cannot rewrite our DOM (#4).
347
- this.root.addEventListener("click", (e) => {
348
- const option = e.target.closest(".rm-menu-option");
349
- if (option) {
350
- e.preventDefault();
351
- e.stopPropagation();
352
- this._selectMenuOption(option);
353
- return;
354
- }
355
- const btn = e.target.closest(".rm-menu-btn");
356
- if (btn) {
357
- e.preventDefault();
358
- e.stopPropagation();
359
- const menu = this._menuForElement(btn);
360
- if (!menu || !this.root.contains(menu)) return;
361
- this._toggleMenu(menu);
362
- return;
363
- }
364
- if (!this._menuForElement(e.target)) this._closeAllMenus();
365
- });
366
- if (!this._boundMenuDocClick) {
367
- this._boundMenuDocClick = (e) => {
368
- if (!this.root || this._menuForElement(e.target)) return;
369
- this._closeAllMenus();
370
- };
371
- document.addEventListener("click", this._boundMenuDocClick);
372
- }
373
-
374
- // Event delegation for cards (status change, edit, delete, or click scrolls to element)
375
- const panelList = document.getElementById("rm-panel-list");
376
- if (!this._boundMenuViewportChange) {
377
- this._boundMenuViewportChange = () => self._closeAllMenus();
378
- window.addEventListener("resize", this._boundMenuViewportChange);
379
- window.addEventListener("scroll", this._boundMenuViewportChange, { passive: true });
380
- }
381
- panelList.addEventListener("scroll", this._boundMenuViewportChange, { passive: true });
382
- panelList.addEventListener("click", (e) => {
383
- const retryBtn = e.target.closest("[data-retry-client-id]");
384
- if (retryBtn) {
385
- e.stopPropagation();
386
- self._retrySync(retryBtn.dataset.retryClientId);
387
- return;
388
- }
389
- // Status menu — leave bubbling for the root menu handler; don't scroll.
390
- if (e.target.closest(".rm-menu")) return;
391
- // Edit button
392
- const editBtn = e.target.closest("[data-edit-id]");
393
- if (editBtn) {
394
- e.stopPropagation();
395
- const id = parseInt(editBtn.dataset.editId, 10);
396
- self._editAnnotation(id);
397
- return;
398
- }
399
- // Delete button
400
- const deleteBtn = e.target.closest("[data-delete-id]");
401
- if (deleteBtn) {
402
- e.stopPropagation();
403
- const id = parseInt(deleteBtn.dataset.deleteId, 10);
404
- self._deleteAnnotation(id);
405
- return;
406
- }
407
- // Card click scrolls to element
408
- const card = e.target.closest("[data-card-id]");
409
- if (!card) return;
410
- const id = parseInt(card.dataset.cardId, 10);
411
- const annotation = self.annotations.find(a => a.id === id);
412
- if (!annotation) return;
413
- const el = self._findElement(annotation);
414
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
415
- });
416
-
417
- // Event delegation for pins (click opens panel + scrolls to card)
418
- document.getElementById("rm-pins-container").addEventListener("click", (e) => {
419
- const pin = e.target.closest("[data-pin-id]");
420
- if (!pin) return;
421
- const panel = document.getElementById("rm-panel");
422
- if (panel.style.display !== "flex") self.togglePanel();
423
- const card = document.querySelector('[data-card-id="' + pin.dataset.pinId + '"]');
424
- if (card) card.scrollIntoView({ behavior: "smooth", block: "center" });
425
- });
426
-
427
- this._boundMouseMove = (e) => self._handleMouseMove(e);
428
- this._boundMouseDown = (e) => self._handleMouseDown(e);
429
- this._boundMouseUp = (e) => self._handleMouseUp(e);
430
- this._boundClick = (e) => self._handleClick(e);
431
- if (!this._boundKeyDown) {
432
- this._boundKeyDown = (e) => self._handleKeyDown(e);
433
- document.addEventListener("keydown", this._boundKeyDown, true);
434
- }
435
- 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 }); } };
436
- 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(){} }); } } };
437
-
438
- // Turbo Frames — partial DOM update, reposition pins
439
- if (!this._boundTurboFrame) {
440
- this._boundTurboFrame = () => self._onTurboFrameRender();
441
- document.addEventListener("turbo:frame-render", this._boundTurboFrame);
442
- }
443
- },
444
-
445
- // ---- Turbo integration ----
446
-
447
- _currentPathname: null,
448
- _currentPageUrl: null,
449
-
450
- _onTurboNavigate() {
451
- const newPageUrl = this._pageUrl();
452
- if (newPageUrl === this._currentPageUrl) return; // same page (anchor change, etc.)
453
- this._currentPathname = window.location.pathname;
454
- this._currentPageUrl = newPageUrl;
455
-
456
- // Deactivate crosshair mode
457
- this._deactivateMode();
458
-
459
- // Close popup
460
- const popup = document.getElementById("rm-popup");
461
- if (popup && popup.style.display === "block") this._closePopup();
462
-
463
- // Close panel
464
- const panel = document.getElementById("rm-panel");
465
- if (panel) panel.style.display = "none";
466
-
467
- // Rerender pins for current page (annotations are global, pins are page-specific)
468
- this._renderPins();
469
- this._updateCount();
470
- this._rebuildList();
471
-
472
- // Re-init session for new URL
473
- this._pullNeeded = true;
474
- if (this.serverOnline) {
475
- this._synchronizeCurrentPage(true).catch(error => console.warn("[rails-markup] page sync failed:", error));
476
- }
477
- },
478
-
479
- _onTurboFrameRender() {
480
- // Frame content changed — DOM elements may have moved, reposition pins
481
- this._repositionPins();
482
- },
483
-
484
- // ---- Mode ----
485
-
486
- toggleMode() {
487
- this.active = !this.active;
488
- if (this.active) {
489
- this._activateMode();
490
- // Always open panel when activating annotation mode
491
- if (document.getElementById("rm-panel").style.display !== "flex") {
492
- this.togglePanel();
493
- }
494
- } else {
495
- this._deactivateMode();
496
- }
497
- },
498
-
499
- _activateMode() {
500
- document.body.style.cursor = "crosshair";
501
- const fab = document.getElementById("rm-fab");
502
- const iconSize = this._fabIconSize();
503
- fab.style.transform = "scale(0.9)";
504
- fab.style.boxShadow = `0 0 0 3px ${this._accentBg()}, 0 0 0 6px rgba(99,102,241,0.2)`;
505
- 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>`;
506
- document.addEventListener("mousemove", this._boundMouseMove, true);
507
- document.addEventListener("mousedown", this._boundMouseDown, true);
508
- document.addEventListener("mouseup", this._boundMouseUp, true);
509
- document.addEventListener("click", this._boundClick, true);
510
- document.addEventListener("touchstart", this._boundTouchStart, true);
511
- document.addEventListener("touchend", this._boundTouchEnd, true);
512
- },
513
-
514
- _deactivateMode() {
515
- this.active = false;
516
- document.body.style.cursor = "";
517
- const fab = document.getElementById("rm-fab");
518
- if (fab) {
519
- const iconSize = this._fabIconSize();
520
- fab.style.transform = "";
521
- fab.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
522
- 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>`;
523
- this._updateCount();
524
- }
525
- document.removeEventListener("mousemove", this._boundMouseMove, true);
526
- document.removeEventListener("mousedown", this._boundMouseDown, true);
527
- document.removeEventListener("mouseup", this._boundMouseUp, true);
528
- document.removeEventListener("click", this._boundClick, true);
529
- document.removeEventListener("touchstart", this._boundTouchStart, true);
530
- document.removeEventListener("touchend", this._boundTouchEnd, true);
531
- this._removeHighlight();
532
- },
533
-
534
- // ---- Panel ----
535
-
536
- togglePanel() {
537
- const panel = document.getElementById("rm-panel");
538
- const fab = document.getElementById("rm-fab");
539
- if (panel.style.display === "flex") {
540
- panel.style.display = "none";
541
- if (fab) fab.setAttribute("aria-expanded", "false");
542
- } else {
543
- panel.style.display = "flex";
544
- if (fab) fab.setAttribute("aria-expanded", "true");
545
- }
546
- },
547
-
548
- // ---- Mouse handlers ----
549
-
550
- _handleMouseMove(event) {
551
- if (!this.active) return;
552
- const el = document.elementFromPoint(event.clientX, event.clientY);
553
- if (!el || this._isToolbar(el)) { this._removeHighlight(); return; }
554
- if (el === this.hoveredElement) return;
555
- this._removeHighlight();
556
- this.hoveredElement = el;
557
- el.dataset.rmOrigOutline = el.style.outline || "";
558
- el.style.outline = `2px solid ${this._accentBg()}`;
559
- el.style.outlineOffset = "2px";
560
- },
561
-
562
- _handleMouseDown(event) {
563
- const el = document.elementFromPoint(event.clientX, event.clientY);
564
- if (el && !this._isToolbar(el)) {
565
- this.clickedElement = el;
566
- // Suppress the press so host controls (buttons, drag handles, form
567
- // fields) don't act before mouseup/click is blocked. Guard for the
568
- // synthetic object passed from the touchstart handler.
569
- if (typeof event.preventDefault === "function") event.preventDefault();
570
- if (typeof event.stopPropagation === "function") event.stopPropagation();
571
- }
572
- },
573
-
574
- async _handleMouseUp(event) {
575
- if (!this.active) return;
576
- const el = this.clickedElement || document.elementFromPoint(event.clientX, event.clientY);
577
- if (!el || this._isToolbar(el)) return;
578
- event.preventDefault();
579
- event.stopPropagation();
580
- const sel = window.getSelection();
581
- this.selectedText = (sel && sel.toString().trim().length > 0) ? sel.toString().trim() : null;
582
- this._currentElement = this._identify(el);
583
- this._currentScreenshot = null;
584
- if (this.enableScreenshots) {
585
- this._currentScreenshot = await this._captureElement(el);
586
- }
587
- this._showPopup(event.clientX, event.clientY);
588
- this.clickedElement = null;
589
- },
590
-
591
- _handleClick(event) {
592
- if (!this.active) return;
593
- const el = event.target;
594
- if (this._isToolbar(el)) return;
595
- // Block link navigation and Turbo visits while annotating
596
- event.preventDefault();
597
- event.stopPropagation();
598
- },
599
-
600
- _handleKeyDown(event) {
601
- const openList = this.root?.querySelector(".rm-menu-list.rm-menu-open");
602
- const openMenu = this._menuForElement(openList);
603
- if (openMenu) {
604
- const options = Array.from(openList.querySelectorAll(".rm-menu-option"));
605
- const focusedIndex = options.indexOf(document.activeElement);
606
-
607
- if (event.key === "Escape") {
608
- this._closeMenu(openMenu, true);
609
- event.preventDefault();
610
- event.stopPropagation();
611
- return;
612
- }
613
- if (event.key === "Tab") {
614
- this._closeMenu(openMenu);
615
- return;
616
- }
617
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
618
- const direction = event.key === "ArrowDown" ? 1 : -1;
619
- const start = focusedIndex === -1 ? 0 : focusedIndex;
620
- const next = (start + direction + options.length) % options.length;
621
- options[next]?.focus();
622
- event.preventDefault();
623
- event.stopPropagation();
624
- return;
625
- }
626
- if (event.key === "Home" || event.key === "End") {
627
- const option = event.key === "Home" ? options[0] : options[options.length - 1];
628
- option?.focus();
629
- event.preventDefault();
630
- event.stopPropagation();
631
- return;
632
- }
633
- if ((event.key === "Enter" || event.key === " ") && focusedIndex !== -1) {
634
- this._selectMenuOption(options[focusedIndex]);
635
- event.preventDefault();
636
- event.stopPropagation();
637
- return;
638
- }
639
- }
640
-
641
- const trigger = event.target.closest?.(".rm-menu-btn");
642
- if (trigger && this.root?.contains(trigger) &&
643
- ["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) {
644
- const menu = this._menuForElement(trigger);
645
- if (menu) this._openMenu(menu);
646
- event.preventDefault();
647
- event.stopPropagation();
648
- return;
649
- }
650
-
651
- if (event.key === "Escape") {
652
- const popup = document.getElementById("rm-popup");
653
- if (popup && popup.style.display === "block") {
654
- this._closePopup();
655
- event.preventDefault();
656
- event.stopPropagation();
657
- return;
658
- }
659
- if (this.active) {
660
- this._deactivateMode();
661
- event.preventDefault();
662
- event.stopPropagation();
663
- return;
664
- }
665
- }
666
- if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
667
- const popup = document.getElementById("rm-popup");
668
- if (popup && popup.style.display === "block") {
669
- this.submitAnnotation();
670
- }
671
- }
672
- },
673
-
674
- // ---- Element identification ----
675
-
676
- _identify(el) {
677
- const tag = el.tagName.toLowerCase();
678
- const id = el.id ? "#" + el.id : "";
679
- const cls = Array.from(el.classList).filter(c => !c.startsWith("rm-")).slice(0, 5).map(c => "." + c).join("");
680
- const text = (el.textContent || "").trim().slice(0, 80);
681
- const rect = el.getBoundingClientRect();
682
- return {
683
- selector: tag + id + cls,
684
- cssPath: this._cssPath(el),
685
- nearbyText: text,
686
- 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) }
687
- };
688
- },
689
-
690
- _cssPath(el) {
691
- const parts = [];
692
- let cur = el;
693
- while (cur && cur !== document.body && parts.length < 5) {
694
- let sel = cur.tagName.toLowerCase();
695
- if (cur.id) { sel += "#" + cur.id; parts.unshift(sel); break; }
696
- const parent = cur.parentElement;
697
- if (parent) {
698
- const sibs = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
699
- if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(cur) + 1) + ")";
700
- }
701
- parts.unshift(sel);
702
- cur = cur.parentElement;
703
- }
704
- return parts.join(" > ");
705
- },
706
-
707
- // ---- Popup ----
708
-
709
- _intentOptions() {
710
- return [
711
- ["fix", "Fix"],
712
- ["change", "Change"],
713
- ["question", "Question"],
714
- ["approve", "Approve"]
715
- ];
716
- },
717
-
718
- _severityOptions() {
719
- return [
720
- ["suggestion", "Suggestion"],
721
- ["important", "Important"],
722
- ["blocking", "Blocking"]
723
- ];
724
- },
725
-
726
- _statusOptions() {
727
- return [
728
- ["pending", "Pending"],
729
- ["acknowledged", "Acknowledged"],
730
- ["resolved", "Resolved"],
731
- ["dismissed", "Dismissed"]
732
- ];
733
- },
734
-
735
- _menuMarkup({ inputId, statusId, label, value, options, compact }) {
736
- const current = options.find(([v]) => v === value) || options[0];
737
- const currentValue = current[0];
738
- const currentLabel = current[1];
739
- const menuId = inputId ? `rm-menu-${inputId}` : `rm-menu-status-${statusId}`;
740
- const listId = `${menuId}-list`;
741
- const inputAttrs = inputId
742
- ? `id="${inputId}"`
743
- : `data-status-id="${statusId}"`;
744
- const optionsHtml = options.map(([v, text]) => {
745
- const active = v === currentValue;
746
- return `<button type="button" class="rm-menu-option${active ? " rm-menu-option-active" : ""}" role="option" data-menu-owner="${menuId}" data-value="${v}" aria-selected="${active ? "true" : "false"}">${text}</button>`;
747
- }).join("");
748
- return `<div class="rm-menu${compact ? " rm-menu-compact" : ""}" id="${menuId}">` +
749
- `<input type="hidden" ${inputAttrs} value="${currentValue}">` +
750
- `<button type="button" class="rm-menu-btn" aria-haspopup="listbox" aria-controls="${listId}" aria-expanded="false" title="${this._esc(label)}" aria-label="${this._esc(label)}">` +
751
- `<span class="rm-menu-label">${currentLabel}</span>` +
752
- `<svg class="rm-menu-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>` +
753
- `</button>` +
754
- `<div class="rm-menu-list${compact ? " rm-menu-list-compact" : ""}" id="${listId}" role="listbox" data-menu-owner="${menuId}" aria-label="${this._esc(label)}">${optionsHtml}</div>` +
755
- `</div>`;
756
- },
757
-
758
- _menuForElement(element) {
759
- if (!element) return null;
760
- const nestedMenu = element.closest?.(".rm-menu");
761
- if (nestedMenu) return nestedMenu;
762
- const ownerId = element.closest?.("[data-menu-owner]")?.dataset.menuOwner;
763
- return ownerId ? document.getElementById(ownerId) : null;
764
- },
765
-
766
- _menuList(menu) {
767
- if (!menu) return null;
768
- return menu.querySelector(".rm-menu-list") ||
769
- document.getElementById(`${menu.id}-list`);
770
- },
771
-
772
- _selectMenuOption(option) {
773
- const menu = this._menuForElement(option);
774
- if (!menu || !this.root?.contains(menu)) return;
775
- const value = option.dataset.value;
776
- const statusInput = menu.querySelector("[data-status-id]");
777
- const statusId = statusInput?.dataset.statusId;
778
- this._setMenuValue(menu, value);
779
- if (!statusId) return;
780
- const id = parseInt(statusId, 10);
781
- if (Number.isNaN(id)) return;
782
- this._changeStatus(id, value);
783
- this.root.querySelector(`[data-status-id="${statusId}"]`)
784
- ?.closest(".rm-menu")
785
- ?.querySelector(".rm-menu-btn")
786
- ?.focus();
787
- },
788
-
789
- _setMenuValue(inputOrMenu, value, restoreFocus = true) {
790
- if (!inputOrMenu) return;
791
- const menu = inputOrMenu.classList?.contains("rm-menu")
792
- ? inputOrMenu
793
- : inputOrMenu.closest?.(".rm-menu");
794
- const input = menu
795
- ? menu.querySelector('input[type="hidden"]')
796
- : inputOrMenu;
797
- if (!input) return;
798
- const resolvedMenu = menu || input.closest(".rm-menu");
799
- input.value = value;
800
- if (!resolvedMenu) return;
801
- const list = this._menuList(resolvedMenu);
802
- const option = list?.querySelector(`.rm-menu-option[data-value="${value}"]`);
803
- const label = resolvedMenu.querySelector(".rm-menu-label");
804
- if (label && option) label.textContent = option.textContent;
805
- list?.querySelectorAll(".rm-menu-option").forEach(opt => {
806
- const active = opt.dataset.value === value;
807
- opt.classList.toggle("rm-menu-option-active", active);
808
- opt.setAttribute("aria-selected", active ? "true" : "false");
809
- });
810
- this._closeMenu(resolvedMenu, restoreFocus);
811
- },
812
-
813
- _toggleMenu(menu) {
814
- const open = this._menuList(menu)?.classList.contains("rm-menu-open");
815
- this._closeAllMenus();
816
- if (!open) this._openMenu(menu);
817
- },
818
-
819
- _openMenu(menu) {
820
- const list = this._menuList(menu);
821
- const btn = menu.querySelector(".rm-menu-btn");
822
- if (!list || !btn) return;
823
- this._closeAllMenus();
824
- const rect = btn.getBoundingClientRect();
825
- const gap = 4;
826
- const viewportPadding = 8;
827
- const compactWidth = menu.classList.contains("rm-menu-compact") ? 120 : 0;
828
- const minimumWidth = Math.max(rect.width || 0, compactWidth);
829
-
830
- list._rmMenuPortalOrigin = {
831
- parent: list.parentNode,
832
- nextSibling: list.nextSibling
833
- };
834
- this.root.appendChild(list);
835
- list.style.pointerEvents = "auto";
836
- list.style.position = "fixed";
837
- list.style.top = "0px";
838
- list.style.bottom = "";
839
- list.style.left = "0px";
840
- list.style.right = "auto";
841
- list.style.minWidth = `${minimumWidth}px`;
842
- list.classList.add("rm-menu-open");
843
- btn.setAttribute("aria-expanded", "true");
844
-
845
- const menuWidth = Math.max(list.offsetWidth || 0, rect.width || 0, compactWidth);
846
- const menuHeight = list.offsetHeight || 0;
847
- const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
848
- const spaceAbove = rect.top - viewportPadding;
849
- const openUp = menuHeight > spaceBelow && spaceAbove > spaceBelow;
850
- const preferredLeft = compactWidth ? rect.right - menuWidth : rect.left;
851
- const maxLeft = Math.max(viewportPadding, window.innerWidth - menuWidth - viewportPadding);
852
- const left = Math.min(Math.max(viewportPadding, preferredLeft), maxLeft);
853
-
854
- list.style.position = "fixed";
855
- list.style.top = openUp ? "" : `${rect.bottom + gap}px`;
856
- list.style.bottom = openUp ? `${window.innerHeight - rect.top + gap}px` : "";
857
- list.style.left = `${left}px`;
858
- list.style.right = "auto";
859
- list.style.minWidth = `${minimumWidth}px`;
860
- const availableHeight = Math.max(0, (openUp ? spaceAbove : spaceBelow) - gap);
861
- list.style.maxHeight = availableHeight ? `${availableHeight}px` : "";
862
- list.style.overflowY = menuHeight > availableHeight && availableHeight > 0 ? "auto" : "";
863
-
864
- const selected = list.querySelector('.rm-menu-option[aria-selected="true"]') ||
865
- list.querySelector(".rm-menu-option");
866
- selected?.focus();
867
- },
868
-
869
- _closeMenu(menu, restoreFocus = false) {
870
- const list = this._menuList(menu);
871
- const btn = menu.querySelector(".rm-menu-btn");
872
- if (list) {
873
- list.classList.remove("rm-menu-open");
874
- ["position", "top", "bottom", "left", "right", "minWidth", "maxHeight", "overflowY", "pointerEvents"]
875
- .forEach(property => { list.style[property] = ""; });
876
- const origin = list._rmMenuPortalOrigin;
877
- if (origin?.parent) {
878
- if (origin.nextSibling?.parentNode === origin.parent) {
879
- origin.parent.insertBefore(list, origin.nextSibling);
880
- } else {
881
- origin.parent.appendChild(list);
882
- }
883
- }
884
- delete list._rmMenuPortalOrigin;
885
- }
886
- if (btn) btn.setAttribute("aria-expanded", "false");
887
- if (restoreFocus) btn?.focus();
888
- },
889
-
890
- _closeAllMenus() {
891
- if (!this.root) return;
892
- this.root.querySelectorAll(".rm-menu").forEach(menu => this._closeMenu(menu));
893
- },
894
-
895
- _showPopup(x, y) {
896
- const popup = document.getElementById("rm-popup");
897
- // Clean up previous drawing elements
898
- const oldContainer = popup.querySelector(".rm-drawing-container");
899
- if (oldContainer) oldContainer.remove();
900
- const oldTools = popup.querySelector("[data-draw]");
901
- if (oldTools) { const p = oldTools.parentElement; if (p && !p.classList.contains("rm-popup-actions")) p.remove(); }
902
- this.drawingCanvas = null;
903
- this.drawingCtx = null;
904
- this.drawingHistory = [];
905
- this.drawingMode = null;
906
- this._screenshotImg = null;
907
-
908
- popup.style.display = "block";
909
- popup.style.opacity = "0";
910
- popup.style.left = "-9999px";
911
- popup.style.top = "-9999px";
912
- popup.style.width = this._currentScreenshot ? "480px" : "360px";
913
- const pw = popup.offsetWidth || (this._currentScreenshot ? 480 : 360);
914
- const ph = popup.offsetHeight || 300;
915
- let left = Math.min(x + 10, window.innerWidth - pw - 20);
916
- let top = Math.min(y + 10, window.innerHeight - ph - 20);
917
- left = Math.max(10, left);
918
- top = Math.max(10, top);
919
- popup.style.left = left + "px";
920
- popup.style.top = top + "px";
921
- requestAnimationFrame(() => {
922
- popup.style.transition = "opacity 0.2s ease";
923
- popup.style.opacity = "1";
924
- });
925
- document.getElementById("rm-popup-el").textContent = this._currentElement.selector;
926
- document.getElementById("rm-popup-text").textContent = this.selectedText
927
- ? '"' + this.selectedText.slice(0, 60) + '"'
928
- : this._currentElement.nearbyText.slice(0, 60);
929
- const input = document.getElementById("rm-popup-input");
930
- input.value = "";
931
- this._setMenuValue(document.getElementById("rm-intent-select"), "change", false);
932
- this._setMenuValue(document.getElementById("rm-severity-select"), "suggestion", false);
933
- document.getElementById("rm-char-count").textContent = "";
934
- document.getElementById("rm-submit-label").textContent = "Add";
935
- this._closeAllMenus();
936
-
937
- // Show screenshot preview with drawing tools
938
- if (this._currentScreenshot) {
939
- this._initDrawing(this._currentScreenshot);
940
- }
941
-
942
- setTimeout(() => input.focus(), 50);
943
- },
944
-
945
- _closePopup() {
946
- this._closeAllMenus();
947
- const popup = document.getElementById("rm-popup");
948
- popup.style.transition = "opacity 0.15s ease";
949
- popup.style.opacity = "0";
950
- setTimeout(() => { popup.style.display = "none"; popup.style.transition = ""; popup.style.opacity = ""; popup.style.width = ""; }, 150);
951
- document.getElementById("rm-popup-input").value = "";
952
- this.selectedText = null;
953
- this._currentElement = null;
954
- this._currentScreenshot = null;
955
- this.drawingCanvas = null;
956
- this.drawingCtx = null;
957
- this.drawingHistory = [];
958
- this.drawingMode = null;
959
- this._screenshotImg = null;
960
- this.editingId = null;
961
- document.getElementById("rm-submit-label").textContent = "Add";
962
- },
963
-
964
- _updateCharCount() {
965
- const len = document.getElementById("rm-popup-input").value.length;
966
- const el = document.getElementById("rm-char-count");
967
- el.textContent = len > 0 ? len : "";
968
- el.style.color = len > 500 ? "#f87171" : "#d1d5db";
969
- },
970
-
971
- // ---- Submit ----
972
-
973
- submitAnnotation(event) {
974
- if (event) event.preventDefault();
975
- const comment = document.getElementById("rm-popup-input").value.trim();
976
- if (!comment) return;
977
- const intent = document.getElementById("rm-intent-select").value;
978
- const severity = document.getElementById("rm-severity-select").value;
979
-
980
- // If drawing canvas exists, merge drawings onto screenshot
981
- const screenshot = this._mergeDrawing() || this._currentScreenshot;
982
-
983
- // Edit existing annotation
984
- if (this.editingId) {
985
- const existing = this.annotations.find(a => a.id === this.editingId);
986
- if (existing) {
987
- const dirtyFields = [];
988
- if (existing.comment !== comment) dirtyFields.push("content");
989
- if (existing.intent !== intent) dirtyFields.push("intent");
990
- if (existing.severity !== severity) dirtyFields.push("severity");
991
- if (screenshot && existing.screenshot !== screenshot) dirtyFields.push("metadata");
992
- const committed = dirtyFields.length === 0 || this._persistLocalMutation("upsert", dirtyFields, () => {
993
- existing.comment = comment;
994
- existing.intent = intent;
995
- existing.severity = severity;
996
- if (screenshot) existing.screenshot = screenshot;
997
- return existing;
998
- });
999
- if (!committed) return;
1000
- this._rebuildList();
1001
- this._closePopup();
1002
- return;
1003
- }
1004
- }
1005
-
1006
- // New annotation
1007
- const annotation = {
1008
- id: this.nextId,
1009
- clientId: this._newClientId(),
1010
- serverId: null,
1011
- serverRevision: 0,
1012
- syncState: "pending",
1013
- serverUpdatedAt: null,
1014
- dirtyFields: [],
1015
- revision: 0,
1016
- comment, intent, severity,
1017
- element: this._currentElement,
1018
- selectedText: this.selectedText || null,
1019
- screenshot: screenshot || null,
1020
- url: window.location.href,
1021
- pathname: this._pageUrl(),
1022
- pageUrl: this._pageUrl(),
1023
- timestamp: new Date().toISOString(),
1024
- status: "pending",
1025
- thread: []
1026
- };
1027
-
1028
- const committed = this._persistLocalMutation("upsert", this._browserCreateFields(), () => {
1029
- this.nextId += 1;
1030
- this.annotations.push(annotation);
1031
- return annotation;
1032
- });
1033
- if (!committed) return;
1034
- this._renderPin(annotation);
1035
- // Rebuild (not append) so a new card honors the active panel filter —
1036
- // e.g. a pending annotation must not show while "Resolved" is selected.
1037
- this._rebuildList();
1038
- this._updateCount();
1039
- this._closePopup();
1040
- },
1041
-
1042
- // ---- Filters ----
1043
-
1044
- _filterAnnotations(filter) {
1045
- this.activeFilter = filter;
1046
- this._updateFilterChips();
1047
- this._rebuildList();
1048
- },
1049
-
1050
- _updateFilterChips() {
1051
- const chips = document.querySelectorAll("#rm-filter-chips [data-filter]");
1052
- chips.forEach(chip => {
1053
- if (chip.dataset.filter === this.activeFilter) {
1054
- chip.className = "rm-chip rm-chip-active";
1055
- chip.style.background = this._accentBg();
1056
- chip.style.color = "#fff";
1057
- } else {
1058
- chip.className = "rm-chip rm-chip-inactive";
1059
- chip.style.background = "#f9fafb";
1060
- chip.style.color = "#9ca3af";
1061
- }
1062
- });
1063
- },
1064
-
1065
- _filteredAnnotations() {
1066
- if (this.activeFilter === "all") return this.annotations;
1067
- if (this.activeFilter === "pending") return this.annotations.filter(a => a.status === "pending" || a.status === "acknowledged");
1068
- if (this.activeFilter === "resolved") return this.annotations.filter(a => a.status === "resolved" || a.status === "dismissed");
1069
- return this.annotations;
1070
- },
1071
-
1072
- // ---- Panel cards ----
1073
-
1074
- _renderCard(annotation) {
1075
- const list = document.getElementById("rm-panel-list");
1076
- const card = document.createElement("div");
1077
- card.className = "rm-card";
1078
- card.dataset.cardId = annotation.id;
1079
-
1080
- const borderColor = annotation.status === "resolved" ? "#10b981" : annotation.status === "dismissed" ? "#d1d5db" : this._accentBg();
1081
- card.style.borderLeftColor = borderColor;
1082
-
1083
- const dotColor = { pending: "#3b82f6", acknowledged: "#f59e0b", resolved: "#10b981", dismissed: "#d1d5db" }[annotation.status] || "#3b82f6";
1084
- const intentColors = { fix: { bg: "#fef2f2", text: "#dc2626" }, change: { bg: "#eff6ff", text: "#2563eb" }, question: { bg: "#f5f3ff", text: "#7c3aed" }, approve: { bg: "#ecfdf5", text: "#059669" } };
1085
- const ic = intentColors[annotation.intent] || intentColors.change;
1086
-
1087
- let threadHtml = "";
1088
- const thread = annotation.thread || [];
1089
- if (thread.length > 0) {
1090
- const last = thread[thread.length - 1];
1091
- threadHtml = `<div class="rm-card-thread" style="border-left-color:${this._accentBg()}"><span class="rm-card-thread-role">${this._esc(last.role || "agent")}</span><div style="margin-top:2px">${this._esc(last.message)}</div></div>`;
1092
- }
1093
-
1094
- card.innerHTML = `
1095
- <div class="rm-card-top">
1096
- <span class="rm-card-dot" style="background:${dotColor}"></span>
1097
- <span class="rm-card-id">#${annotation.id}</span>
1098
- <span class="rm-card-badge" style="background:${ic.bg};color:${ic.text}">${annotation.intent}</span>
1099
- ${annotation.severity !== "suggestion" ? '<span class="rm-card-badge" style="background:#fff7ed;color:#9a3412">' + annotation.severity + '</span>' : ''}
1100
- <span style="margin-left:auto;display:flex;gap:2px;align-items:center;">
1101
- ${this._menuMarkup({
1102
- statusId: annotation.id,
1103
- label: "Change status",
1104
- value: annotation.status,
1105
- options: this._statusOptions(),
1106
- compact: true
1107
- })}
1108
- <button data-edit-id="${annotation.id}" title="Edit" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#d1d5db;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#6b7280'" onmouseout="this.style.color='#d1d5db'">
1109
- <svg viewBox="0 0 24 24" style="width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
1110
- </button>
1111
- <button data-delete-id="${annotation.id}" title="Delete" style="padding:2px 4px;background:none;border:none;cursor:pointer;color:#d1d5db;border-radius:4px;display:flex;align-items:center;" onmouseover="this.style.color='#ef4444'" onmouseout="this.style.color='#d1d5db'">
1112
- <svg viewBox="0 0 24 24" style="width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
1113
- </button>
1114
- </span>
1115
- </div>
1116
- <div class="rm-card-body">${this._esc(annotation.comment)}</div>
1117
- <div class="rm-card-path">${this._esc(annotation.pathname || "")} &rsaquo; ${this._esc(annotation.element?.selector || "")}</div>
1118
- ${annotation.selectedText ? '<div class="rm-card-path" style="font-style:italic">"' + this._esc(annotation.selectedText.slice(0, 60)) + '"</div>' : ''}
1119
- ${threadHtml}
1120
- `;
1121
-
1122
- list.appendChild(card);
1123
- },
1124
-
1125
- _rebuildList() {
1126
- const list = document.getElementById("rm-panel-list");
1127
- if (!list) return;
1128
- this._closeAllMenus();
1129
- list.innerHTML = "";
1130
- this._renderStorageError(list);
1131
- this._renderFailedSync(list);
1132
- const filtered = this._filteredAnnotations();
1133
- if (filtered.length === 0) {
1134
- const empty = document.createElement("div");
1135
- empty.className = "rm-empty";
1136
- empty.innerHTML = '<div class="rm-empty-icon">&#9670;</div><div class="rm-empty-text">No annotations yet</div>';
1137
- list.appendChild(empty);
1138
- return;
1139
- }
1140
- filtered.forEach(a => this._renderCard(a));
1141
- },
1142
-
1143
- _renderStorageError(list) {
1144
- if (!this._storageError) return;
1145
- const error = document.createElement("div");
1146
- error.className = "rm-storage-error";
1147
- error.setAttribute("role", "alert");
1148
- error.style.cssText = "padding:8px;margin-bottom:8px;border:1px solid #fecaca;border-radius:8px;background:#fef2f2;color:#991b1b;font-size:11px;";
1149
- error.textContent = this._storageError;
1150
- list.appendChild(error);
1151
- },
1152
-
1153
- _renderFailedSync(list) {
1154
- const failed = Object.values(this.outbox || {}).filter(entry => entry?.syncState === "failed");
1155
- if (failed.length === 0) return;
1156
- const section = document.createElement("div");
1157
- section.className = "rm-failed-sync";
1158
- section.setAttribute("role", "status");
1159
- failed.forEach(entry => {
1160
- const item = document.createElement("div");
1161
- item.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px;margin-bottom:8px;border:1px solid #fecaca;border-radius:8px;background:#fef2f2;color:#991b1b;font-size:11px;";
1162
- const label = entry.type === "delete" ? "Delete failed" : "Sync failed";
1163
- item.innerHTML = `<span style="flex:1">${label}</span><button type="button" data-retry-client-id="${this._esc(entry.clientId)}" style="border:1px solid #fca5a5;border-radius:6px;background:#fff;padding:3px 7px;color:#991b1b;cursor:pointer">Retry</button>`;
1164
- section.appendChild(item);
1165
- });
1166
- list.appendChild(section);
1167
- },
1168
-
1169
- _editAnnotation(id) {
1170
- const annotation = this.annotations.find(a => a.id === id);
1171
- if (!annotation) return;
1172
- this.editingId = id;
1173
- this._currentElement = annotation.element;
1174
- this.selectedText = annotation.selectedText;
1175
- this._currentScreenshot = annotation.screenshot || null;
1176
-
1177
- // Pre-fill popup fields
1178
- document.getElementById("rm-popup-el").textContent = annotation.element?.selector || "";
1179
- document.getElementById("rm-popup-text").textContent = annotation.selectedText
1180
- ? '"' + annotation.selectedText.slice(0, 60) + '"'
1181
- : (annotation.element?.nearbyText || "").slice(0, 60);
1182
- document.getElementById("rm-popup-input").value = annotation.comment;
1183
- this._setMenuValue(document.getElementById("rm-intent-select"), annotation.intent, false);
1184
- this._setMenuValue(document.getElementById("rm-severity-select"), annotation.severity, false);
1185
- document.getElementById("rm-submit-label").textContent = "Save";
1186
- this._updateCharCount();
1187
- this._closeAllMenus();
1188
-
1189
- // Clean up previous drawing elements
1190
- const popup = document.getElementById("rm-popup");
1191
- const oldContainer = popup.querySelector(".rm-drawing-container");
1192
- if (oldContainer) oldContainer.remove();
1193
- const oldTools = popup.querySelector("[data-draw]");
1194
- if (oldTools) { const p = oldTools.parentElement; if (p && !p.classList.contains("rm-popup-actions")) p.remove(); }
1195
- this.drawingCanvas = null;
1196
- this.drawingCtx = null;
1197
- this.drawingHistory = [];
1198
- this.drawingMode = null;
1199
- this._screenshotImg = null;
1200
-
1201
- // Position popup centered on screen
1202
- popup.style.display = "block";
1203
- popup.style.width = this._currentScreenshot ? "480px" : "360px";
1204
- const pw = popup.offsetWidth || 360;
1205
- const ph = popup.offsetHeight || 300;
1206
- popup.style.left = Math.max(10, Math.round((window.innerWidth - pw) / 2)) + "px";
1207
- popup.style.top = Math.max(10, Math.round((window.innerHeight - ph) / 2)) + "px";
1208
- popup.style.opacity = "1";
1209
-
1210
- if (this._currentScreenshot) {
1211
- this._initDrawing(this._currentScreenshot);
1212
- }
1213
-
1214
- setTimeout(() => document.getElementById("rm-popup-input").focus(), 50);
1215
- },
1216
-
1217
- _changeStatus(id, newStatus) {
1218
- const annotation = this.annotations.find(a => a.id === id);
1219
- if (!annotation) return;
1220
- const committed = this._persistLocalMutation("upsert", ["status"], () => {
1221
- annotation.status = newStatus;
1222
- return annotation;
1223
- });
1224
- if (!committed) return;
1225
- this._renderPins();
1226
- this._rebuildList();
1227
- const label = newStatus.charAt(0).toUpperCase() + newStatus.slice(1);
1228
- this._showToast(`#${id} marked as ${label}`, newStatus === "resolved" ? "resolved" : "dismissed");
1229
- },
1230
-
1231
- _deleteAnnotation(id) {
1232
- const idx = this.annotations.findIndex(a => a.id === id);
1233
- if (idx === -1) return;
1234
- const committed = this._persistLocalMutation("delete", [], () => this.annotations.splice(idx, 1)[0]);
1235
- if (!committed) return;
1236
- this._renderPins();
1237
- this._rebuildList();
1238
- this._updateCount();
1239
- },
1240
-
1241
- _updateCount() {
1242
- const count = this.annotations.length;
1243
- const countEl = document.getElementById("rm-panel-count");
1244
- if (countEl) countEl.textContent = count;
1245
- const badge = document.getElementById("rm-fab-badge");
1246
- if (badge) {
1247
- if (count > 0) { badge.textContent = count; badge.style.display = "flex"; }
1248
- else { badge.style.display = "none"; }
1249
- }
1250
- const toggle = document.getElementById("rm-panel-toggle");
1251
- if (toggle) toggle.style.display = "flex";
1252
- },
1253
-
1254
- // ---- Pins ----
1255
-
1256
- _renderPin(annotation) {
1257
- if (!annotation.element?.boundingBox) return;
1258
- const { top, left, width } = annotation.element.boundingBox;
1259
- const container = document.getElementById("rm-pins-container");
1260
- if (!container) return;
1261
- const isResolved = annotation.status === "resolved" || annotation.status === "dismissed";
1262
- const pin = document.createElement("div");
1263
- pin.className = "rm-pin" + (isResolved ? "" : " rm-pin-active");
1264
- pin.dataset.pinId = annotation.id;
1265
- pin.style.top = (top - 10) + "px";
1266
- pin.style.left = (left + width - 10) + "px";
1267
- pin.style.background = isResolved ? "#d1d5db" : this._accentBg();
1268
- if (isResolved) pin.style.opacity = "0.6";
1269
- pin.textContent = annotation.id;
1270
- pin.title = "#" + annotation.id + ": " + annotation.comment.slice(0, 50);
1271
- container.appendChild(pin);
1272
- },
1273
-
1274
- _renderPins() {
1275
- const container = document.getElementById("rm-pins-container");
1276
- if (container) container.innerHTML = "";
1277
- // Only render pins for annotations on the current page
1278
- const currentPath = this._pageUrl();
1279
- this.annotations
1280
- .filter(a => (a.pageUrl || a.pathname) === currentPath)
1281
- .forEach(a => this._renderPin(a));
1282
- },
1283
-
1284
- _findElement(annotation) {
1285
- if (!annotation.element) return null;
1286
- const { cssPath, selector } = annotation.element;
1287
- if (cssPath) { try { const el = document.querySelector(cssPath); if (el) return el; } catch {} }
1288
- if (selector) { try { const el = document.querySelector(selector); if (el) return el; } catch {} }
1289
- return null;
1290
- },
1291
-
1292
- _repositionPins() {
1293
- this.annotations.forEach(annotation => {
1294
- const el = this._findElement(annotation);
1295
- if (!el) return;
1296
- const rect = el.getBoundingClientRect();
1297
- annotation.element.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) };
1298
- const pin = document.querySelector('[data-pin-id="' + annotation.id + '"]');
1299
- if (pin) { pin.style.top = (annotation.element.boundingBox.top - 10) + "px"; pin.style.left = (annotation.element.boundingBox.left + annotation.element.boundingBox.width - 10) + "px"; }
1300
- });
1301
- },
1302
-
1303
- _debouncedRepositionPins(delay = 250) {
1304
- let timer = null;
1305
- return () => { if (timer) clearTimeout(timer); timer = setTimeout(() => this._repositionPins(), delay); };
1306
- },
1307
-
1308
- // ---- Highlight ----
1309
-
1310
- _removeHighlight() {
1311
- if (this.hoveredElement) {
1312
- this.hoveredElement.style.outline = this.hoveredElement.dataset.rmOrigOutline || "";
1313
- this.hoveredElement.style.outlineOffset = "";
1314
- delete this.hoveredElement.dataset.rmOrigOutline;
1315
- this.hoveredElement = null;
1316
- }
1317
- },
1318
-
1319
- _isToolbar(el) {
1320
- const root = document.getElementById("rm-toolbar-root");
1321
- return root && root.contains(el);
1322
- },
1323
-
1324
- // ---- Toast ----
1325
-
1326
- _showToast(message, type) {
1327
- const container = document.getElementById("rm-toast-container");
1328
- if (!container) return;
1329
- const toast = document.createElement("div");
1330
- toast.className = "rm-toast";
1331
- const colors = { resolved: { bg: "#ecfdf5", border: "#a7f3d0", text: "#065f46" }, dismissed: { bg: "#f3f4f6", border: "#e5e7eb", text: "#6b7280" } };
1332
- const c = colors[type] || { bg: this._accentLight(), border: this._accentBg(), text: this._accentText() };
1333
- toast.style.background = c.bg;
1334
- toast.style.borderColor = c.border;
1335
- toast.style.color = c.text;
1336
- toast.textContent = message;
1337
- container.appendChild(toast);
1338
- setTimeout(() => { toast.style.animation = "rm-toast-out 0.3s ease forwards"; setTimeout(() => toast.remove(), 300); }, 4000);
1339
- },
1340
-
1341
- // ---- Storage ----
1342
-
1343
- _storageKey() {
1344
- const endpoint = (this.endpoint || "/feedback/api").replace(/\/+$/, "") || "/";
1345
- return `rm-annotations:${encodeURIComponent(endpoint)}`;
1346
- },
1347
- _pageUrl() { return window.location.pathname + window.location.search; },
1348
- _pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
1349
-
1350
- _saveToStorage() {
1351
- try {
1352
- // Cross-tab storage-event merging is deferred: safely reconciling ordered
1353
- // upserts and tombstones needs conflict semantics, not a last-write merge.
1354
- localStorage.setItem(this._storageKey(), JSON.stringify({
1355
- annotations: this.annotations,
1356
- nextId: this.nextId,
1357
- outbox: this.outbox,
1358
- legacyMigrations: this.legacyMigrations
1359
- }));
1360
- return true;
1361
- }
1362
- catch (e) {
1363
- console.warn("[rails-markup] save failed:", e);
1364
- return false;
1365
- }
1366
- },
1367
-
1368
- _persistLocalMutation(type, dirtyFields, mutate) {
1369
- return this._commitLocalStateChange(() => {
1370
- const annotation = mutate();
1371
- this._queueLocalMutation(type, annotation, dirtyFields);
1372
- });
1373
- },
1374
-
1375
- _queueLocalMutation(type, annotation, dirtyFields) {
1376
- const currentEntry = this.outbox[annotation.clientId];
1377
- const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
1378
- const baseRevision = Number.isInteger(currentEntry?.baseRevision)
1379
- ? currentEntry.baseRevision
1380
- : (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
1381
-
1382
- if (type === "delete") {
1383
- this.outbox[annotation.clientId] = {
1384
- type: "delete",
1385
- clientId: annotation.clientId,
1386
- revision,
1387
- baseRevision,
1388
- syncState: "pending"
1389
- };
1390
- } else {
1391
- const existingDirtyFields = currentEntry?.type === "upsert" ? currentEntry.dirtyFields : [];
1392
- annotation.dirtyFields = this._mergeDirtyFields(annotation.dirtyFields, existingDirtyFields, dirtyFields);
1393
- annotation.syncState = "pending";
1394
- annotation.revision = revision;
1395
- this.outbox[annotation.clientId] = {
1396
- type: "upsert",
1397
- clientId: annotation.clientId,
1398
- revision,
1399
- baseRevision,
1400
- syncState: "pending",
1401
- annotation: this._desiredState(annotation),
1402
- dirtyFields: annotation.dirtyFields.slice()
1403
- };
1404
- }
1405
- },
1406
-
1407
- _commitLocalStateChange(mutate) {
1408
- const snapshot = this._localStateSnapshot();
1409
- mutate();
1410
- if (this._saveToStorage()) {
1411
- this._storageError = null;
1412
- this._scheduleOutboxFlush();
1413
- return true;
1414
- }
1415
-
1416
- this.annotations = snapshot.annotations;
1417
- this.outbox = snapshot.outbox;
1418
- this.nextId = snapshot.nextId;
1419
- this.legacyMigrations = snapshot.legacyMigrations;
1420
- this._storageError = "Changes could not be saved in this browser. Free storage space and try again.";
1421
- this._renderPins();
1422
- this._rebuildList();
1423
- this._updateCount();
1424
- const panel = document.getElementById("rm-panel");
1425
- if (panel) panel.style.display = "flex";
1426
- const fab = document.getElementById("rm-fab");
1427
- if (fab) fab.setAttribute("aria-expanded", "true");
1428
- return false;
1429
- },
1430
-
1431
- _localStateSnapshot() {
1432
- return JSON.parse(JSON.stringify({
1433
- annotations: this.annotations,
1434
- outbox: this.outbox,
1435
- nextId: this.nextId,
1436
- legacyMigrations: this.legacyMigrations
1437
- }));
1438
- },
1439
-
1440
- _mergeDirtyFields(...collections) {
1441
- const merged = [];
1442
- collections.flat().forEach(field => {
1443
- const canonical = field === "selectedText" ? "selected_text" : field;
1444
- if (canonical && !merged.includes(canonical)) merged.push(canonical);
1445
- });
1446
- return merged;
1447
- },
1448
-
1449
- _scheduleOutboxFlush() {
1450
- if (this._outboxFlushScheduled) return;
1451
- this._outboxFlushScheduled = true;
1452
- this._outboxFlushTimer = setTimeout(() => {
1453
- this._outboxFlushScheduled = false;
1454
- this._outboxFlushTimer = null;
1455
- Promise.resolve(this._flushOutbox()).catch(error => console.warn("[rails-markup] outbox flush failed:", error));
1456
- }, 0);
1457
- },
1458
-
1459
- _flushOutbox() {
1460
- if (this._flushPromise) return this._flushPromise;
1461
- if (this._pullPromise) {
1462
- if (!this._flushAfterPullPromise) {
1463
- this._flushAfterPullPromise = this._pullPromise.then(() => {
1464
- this._flushAfterPullPromise = null;
1465
- return this._flushOutbox();
1466
- });
1467
- }
1468
- return this._flushAfterPullPromise;
1469
- }
1470
- if (!navigator.onLine || !this.serverOnline) return Promise.resolve();
1471
- if (this._syncRetryTimer) return Promise.resolve();
1472
-
1473
- this._flushPromise = this._runOutboxFlush().finally(() => {
1474
- this._flushPromise = null;
1475
- });
1476
- return this._flushPromise;
1477
- },
1478
-
1479
- async _runOutboxFlush() {
1480
- const clientIds = Object.keys(this.outbox);
1481
- for (const clientId of clientIds) {
1482
- const current = this.outbox[clientId];
1483
- if (!current || current.syncState === "failed") continue;
1484
-
1485
- const snapshot = this._immutableCopy(current);
1486
- let result;
1487
- try {
1488
- result = await this._sendOutboxEntry(snapshot);
1489
- } catch (error) {
1490
- result = { kind: "retryable", error };
1491
- }
1492
-
1493
- if (result.kind === "success") {
1494
- if (!this._outboxEntryMatches(snapshot)) {
1495
- const supersedingDelete = this._advanceSupersedingDeleteBaseRevision(snapshot, result.data);
1496
- if (supersedingDelete === "failed") break;
1497
- clientIds.push(clientId);
1498
- continue;
1499
- }
1500
- this._handleSyncSuccess(snapshot, result.data);
1501
- this._resetSyncRetry();
1502
- continue;
1503
- }
1504
- if (result.kind === "auth") {
1505
- this._setSyncUnavailable(result.message || "Authentication required");
1506
- break;
1507
- }
1508
- if (result.kind === "terminal") {
1509
- if (!this._outboxEntryMatches(snapshot)) {
1510
- clientIds.push(clientId);
1511
- continue;
1512
- }
1513
- this._markSyncFailed(snapshot);
1514
- continue;
1515
- }
1516
- if (result.kind === "conflict") {
1517
- const resolution = this._reconcileSyncConflict(snapshot, result);
1518
- await this._pullAnnotations();
1519
- if (resolution === "retry" && this.outbox[clientId]) {
1520
- clientIds.push(clientId);
1521
- continue;
1522
- }
1523
- this._resetSyncRetry();
1524
- continue;
1525
- }
1526
- if (result.kind === "malformed") {
1527
- if (!this._outboxEntryMatches(snapshot)) {
1528
- clientIds.push(clientId);
1529
- continue;
1530
- }
1531
- if (this._recordMalformedResponse(snapshot)) continue;
1532
- break;
1533
- }
1534
-
1535
- this.serverOnline = false;
1536
- this._updateStatus();
1537
- this._scheduleSyncRetry(result.retryAfter);
1538
- break;
1539
- }
1540
- },
1541
-
1542
- _advanceSupersedingDeleteBaseRevision(snapshot, server) {
1543
- const current = this.outbox[snapshot.clientId];
1544
- const supersedesUpsert = snapshot.type === "upsert"
1545
- && current?.type === "delete"
1546
- && current.clientId === snapshot.clientId
1547
- && Number.isInteger(current.revision)
1548
- && current.revision > snapshot.revision
1549
- && Number.isInteger(server?.revision);
1550
- if (!supersedesUpsert) return "not-applicable";
1551
-
1552
- const tombstone = this._immutableCopy(current);
1553
- const committed = this._commitLocalStateChange(() => {
1554
- if (!this._outboxEntryMatches(tombstone)) return;
1555
- const baseRevision = Number.isInteger(tombstone.baseRevision) ? tombstone.baseRevision : 0;
1556
- this.outbox[snapshot.clientId].baseRevision = Math.max(baseRevision, server.revision);
1557
- });
1558
- return committed ? "advanced" : "failed";
1559
- },
1560
-
1561
- async _sendOutboxEntry(snapshot) {
1562
- const request = this._sameOriginMutationRequest(`/annotations/${encodeURIComponent(snapshot.clientId)}`);
1563
- if (!request) return { kind: "auth", message: "Sync unavailable: endpoint must be same-origin" };
1564
-
1565
- const options = {
1566
- method: snapshot.type === "delete" ? "DELETE" : "PUT",
1567
- headers: request.headers,
1568
- credentials: "same-origin",
1569
- redirect: "manual",
1570
- signal: AbortSignal.timeout(5000)
1571
- };
1572
- if (snapshot.type === "delete") {
1573
- options.body = JSON.stringify({
1574
- baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1575
- });
1576
- } else {
1577
- options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
1578
- dirtyFields: snapshot.dirtyFields || [],
1579
- baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1580
- }));
1581
- }
1582
-
1583
- const response = await fetch(request.url, options);
1584
- return this._classifySyncResponse(snapshot, response);
1585
- },
1586
-
1587
- _sameOriginMutationRequest(path) {
1588
- const url = this._sameOriginEndpointUrl(path);
1589
- if (!url) return null;
1590
- const headers = { "Content-Type": "application/json", "Accept": "application/json" };
1591
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1592
- if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
1593
- return { url, headers };
1594
- },
1595
-
1596
- _sameOriginEndpointUrl(path) {
1597
- try {
1598
- const rawUrl = `${this.endpoint.replace(/\/$/, "")}/${String(path).replace(/^\//, "")}`;
1599
- const resolved = new URL(rawUrl, window.location.href);
1600
- if (resolved.origin !== window.location.origin) return null;
1601
- return this.endpoint.startsWith("/") && !this.endpoint.startsWith("//")
1602
- ? resolved.pathname + resolved.search
1603
- : resolved.href;
1604
- } catch {
1605
- return null;
1606
- }
1607
- },
1608
-
1609
- async _classifySyncResponse(snapshot, response) {
1610
- const status = response.status;
1611
- if (status >= 300 && status < 400) return { kind: "auth", message: "Authentication required" };
1612
- if (status === 401 || status === 403 || response.type === "opaqueredirect") {
1613
- return { kind: "auth", message: "Authentication required" };
1614
- }
1615
- if ([408, 425, 429].includes(status) || status >= 500) {
1616
- return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1617
- }
1618
- if (status === 409) return this._classifyConflictResponse(snapshot, response);
1619
- if (status >= 400) return { kind: "terminal" };
1620
- if (!response.ok) return { kind: "retryable" };
1621
- if (snapshot.type === "delete") return { kind: "success", data: null };
1622
-
1623
- const contentType = response.headers.get("Content-Type") || "";
1624
- if (!contentType.toLowerCase().includes("application/json")) {
1625
- return { kind: "auth", message: "Sync unavailable: expected JSON" };
1626
- }
1627
-
1628
- try {
1629
- const data = await response.json();
1630
- if (!this._validServerAnnotation(data, snapshot.clientId)) return { kind: "malformed" };
1631
- return { kind: "success", data };
1632
- } catch {
1633
- return { kind: "malformed" };
1634
- }
1635
- },
1636
-
1637
- async _classifyConflictResponse(snapshot, response) {
1638
- const contentType = response.headers.get("Content-Type") || "";
1639
- if (!contentType.toLowerCase().includes("application/json")) return { kind: "malformed" };
1640
-
1641
- try {
1642
- const body = await response.json();
1643
- if (!this._plainObject(body) || !Object.prototype.hasOwnProperty.call(body, "annotation")) {
1644
- return { kind: "malformed" };
1645
- }
1646
- if (body.annotation === null && snapshot.type === "upsert") {
1647
- return { kind: "conflict", missing: true, data: null };
1648
- }
1649
- if (!this._validServerAnnotation(body.annotation, snapshot.clientId)) return { kind: "malformed" };
1650
- return { kind: "conflict", missing: false, data: body.annotation };
1651
- } catch {
1652
- return { kind: "malformed" };
1653
- }
1654
- },
1655
-
1656
- _validServerAnnotation(data, expectedClientId) {
1657
- if (!this._plainObject(data)) return false;
1658
- const required = [
1659
- "id", "clientId", "userId", "authorName", "content", "intent", "severity",
1660
- "status", "selectedText", "pageUrl", "target", "metadata", "thread",
1661
- "createdAt", "updatedAt", "revision"
1662
- ];
1663
- if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
1664
- if (typeof data.id !== "string" || data.id.length === 0) return false;
1665
- if (data.clientId !== expectedClientId) return false;
1666
- if (!(data.userId === null || Number.isInteger(data.userId))) return false;
1667
- if (!(data.authorName === null || typeof data.authorName === "string")) return false;
1668
- if (typeof data.content !== "string") return false;
1669
- if (!["fix", "change", "question", "approve"].includes(data.intent)) return false;
1670
- if (!["suggestion", "important", "blocking"].includes(data.severity)) return false;
1671
- if (!["pending", "acknowledged", "resolved", "dismissed"].includes(data.status)) return false;
1672
- if (!(data.selectedText === null || typeof data.selectedText === "string")) return false;
1673
- if (typeof data.pageUrl !== "string" || data.pageUrl.length === 0) return false;
1674
- if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
1675
- if (!Array.isArray(data.thread)) return false;
1676
- if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
1677
- if (!Number.isInteger(data.revision) || data.revision < 0) return false;
1678
- return true;
1679
- },
1680
-
1681
- _plainObject(value) {
1682
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1683
- },
1684
-
1685
- _validServerTimestamp(value) {
1686
- return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
1687
- },
1688
-
1689
- _handleSyncSuccess(snapshot, data) {
1690
- if (!this._outboxEntryMatches(snapshot)) return;
1691
- this._commitLocalStateChange(() => {
1692
- if (!this._outboxEntryMatches(snapshot)) return;
1693
- if (snapshot.type === "upsert") {
1694
- const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1695
- if (annotation) this._mergeSuccessfulUpsert(annotation, data, snapshot.dirtyFields || []);
1696
- }
1697
- delete this.outbox[snapshot.clientId];
1698
- });
1699
- this._renderPins();
1700
- this._rebuildList();
1701
- this._updateCount();
1702
- },
1703
-
1704
- _mergeSuccessfulUpsert(annotation, server, sentDirtyFields) {
1705
- if (this._serverRepresentationIsStale(annotation, server)) {
1706
- annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
1707
- annotation.syncState = "synced";
1708
- return;
1709
- }
1710
- annotation.serverId = server.id;
1711
- annotation.serverRevision = server.revision;
1712
- annotation.userId = server.userId;
1713
- annotation.authorName = server.authorName;
1714
- annotation.createdAt = server.createdAt;
1715
- annotation.serverUpdatedAt = server.updatedAt || annotation.serverUpdatedAt;
1716
- annotation.comment = server.content;
1717
- annotation.intent = server.intent;
1718
- annotation.severity = server.severity;
1719
- annotation.status = server.status;
1720
- annotation.selectedText = server.selectedText;
1721
- annotation.element = server.target || {};
1722
- annotation.metadata = server.metadata || {};
1723
- annotation.thread = server.thread || [];
1724
- annotation.pageUrl = server.pageUrl || annotation.pageUrl;
1725
- annotation.pathname = server.pageUrl || annotation.pathname;
1726
- annotation.dirtyFields = (annotation.dirtyFields || []).filter(field => !sentDirtyFields.includes(field));
1727
- annotation.syncState = "synced";
1728
- },
1729
-
1730
- _reconcileSyncConflict(snapshot, conflict) {
1731
- if (!this._outboxEntryMatches(snapshot)) return "stop";
1732
-
1733
- let resolution = "stop";
1734
- const committed = this._commitLocalStateChange(() => {
1735
- if (!this._outboxEntryMatches(snapshot)) return;
1736
- const entry = this.outbox[snapshot.clientId];
1737
- const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1738
-
1739
- if (snapshot.type === "delete") {
1740
- delete this.outbox[snapshot.clientId];
1741
- if (annotation) this._mergePulledAnnotation(annotation, conflict.data, null);
1742
- else this.annotations.push(this._annotationFromServer(conflict.data));
1743
- this._assignDisplayIds();
1744
- return;
1745
- }
1746
-
1747
- if (conflict.missing) {
1748
- if (entry.missingConflictRebased) {
1749
- entry.syncState = "failed";
1750
- if (annotation) annotation.syncState = "failed";
1751
- return;
1752
- }
1753
- if (!annotation) {
1754
- entry.syncState = "failed";
1755
- return;
1756
- }
1757
- // Recreate once from the complete desired browser state. Replaying a
1758
- // narrow edit delta onto a new row can omit required fields and lose
1759
- // the user's annotation to a permanent validation failure.
1760
- annotation.dirtyFields = this._legacyDirtyFields(annotation);
1761
- entry.annotation = this._desiredState(annotation);
1762
- entry.dirtyFields = annotation.dirtyFields.slice();
1763
- entry.baseRevision = 0;
1764
- entry.missingConflictRebased = true;
1765
- annotation.serverId = null;
1766
- annotation.serverRevision = 0;
1767
- resolution = "retry";
1768
- return;
1769
- }
1770
-
1771
- if (!annotation) {
1772
- entry.syncState = "failed";
1773
- return;
1774
- }
1775
- this._mergePulledAnnotation(annotation, conflict.data, entry);
1776
- entry.annotation = this._desiredState(annotation);
1777
- entry.dirtyFields = (annotation.dirtyFields || []).slice();
1778
- entry.baseRevision = conflict.data.revision;
1779
- delete entry.missingConflictRebased;
1780
- resolution = "retry";
1781
- });
1782
- if (!committed) return "stop";
1783
- this._renderPins();
1784
- this._rebuildList();
1785
- this._updateCount();
1786
- return resolution;
1787
- },
1788
-
1789
- _markSyncFailed(snapshot) {
1790
- if (!this._outboxEntryMatches(snapshot)) return;
1791
- this._commitLocalStateChange(() => {
1792
- const entry = this.outbox[snapshot.clientId];
1793
- if (!entry || !this._outboxEntryMatches(snapshot)) return;
1794
- entry.syncState = "failed";
1795
- const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1796
- if (annotation) annotation.syncState = "failed";
1797
- });
1798
- this._rebuildList();
1799
- },
1800
-
1801
- _recordMalformedResponse(snapshot) {
1802
- if (!this._outboxEntryMatches(snapshot)) return true;
1803
- const attempts = (this.outbox[snapshot.clientId].malformedAttempts || 0) + 1;
1804
- if (attempts >= this._syncMalformedLimit) {
1805
- this._markSyncFailed(snapshot);
1806
- return true;
1807
- }
1808
- this._commitLocalStateChange(() => {
1809
- if (this._outboxEntryMatches(snapshot)) this.outbox[snapshot.clientId].malformedAttempts = attempts;
1810
- });
1811
- this._scheduleSyncRetry();
1812
- return false;
1813
- },
1814
-
1815
- _outboxEntryMatches(snapshot) {
1816
- const current = this.outbox[snapshot.clientId];
1817
- return Boolean(current) && JSON.stringify(current) === JSON.stringify(snapshot);
1818
- },
1819
-
1820
- _immutableCopy(value) {
1821
- return JSON.parse(JSON.stringify(value));
1822
- },
1823
-
1824
- _retryAfterDelay(response) {
1825
- const value = response.headers.get("Retry-After");
1826
- if (!value) return null;
1827
- const seconds = Number(value);
1828
- const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
1829
- if (!Number.isFinite(delay)) return null;
1830
- return Math.min(this._syncMaxRetryDelay, Math.max(0, delay));
1831
- },
1832
-
1833
- _scheduleSyncRetry(requestedDelay) {
1834
- if (this._syncRetryTimer) return;
1835
- this._syncRetryAttempt += 1;
1836
- const exponential = this._syncBaseRetryDelay * (2 ** (this._syncRetryAttempt - 1));
1837
- this._syncRetryDelay = Math.min(this._syncMaxRetryDelay, requestedDelay ?? exponential);
1838
- this._syncRetryTimer = setTimeout(async () => {
1839
- this._syncRetryTimer = null;
1840
- await this._checkHealth();
1841
- }, this._syncRetryDelay);
1842
- },
1843
-
1844
- _resetSyncRetry() {
1845
- this._syncRetryAttempt = 0;
1846
- this._syncRetryDelay = 0;
1847
- if (this._syncRetryTimer) clearTimeout(this._syncRetryTimer);
1848
- this._syncRetryTimer = null;
1849
- this._syncUnavailable = null;
1850
- },
1851
-
1852
- _setSyncUnavailable(message) {
1853
- this.serverOnline = false;
1854
- this._syncUnavailable = message;
1855
- this._updateStatus();
1856
- },
1857
-
1858
- _retrySync(clientId) {
1859
- const entry = this.outbox[clientId];
1860
- if (!entry || entry.syncState !== "failed") return;
1861
- const committed = this._commitLocalStateChange(() => {
1862
- entry.syncState = "pending";
1863
- const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
1864
- if (annotation) annotation.syncState = "pending";
1865
- });
1866
- if (!committed) return;
1867
- this._rebuildList();
1868
- },
1869
-
1870
- _loadFromStorage() {
1871
- try {
1872
- let raw = localStorage.getItem(this._storageKey());
1873
- if (raw) {
1874
- const data = JSON.parse(raw);
1875
- const validDocument = data && typeof data === "object" && !Array.isArray(data);
1876
- this.annotations = validDocument && Array.isArray(data.annotations) ? data.annotations : [];
1877
- this.nextId = validDocument && Number.isInteger(data.nextId) && data.nextId > 0 ? data.nextId : (this.annotations.length + 1);
1878
- this.outbox = validDocument && data.outbox && typeof data.outbox === "object" && !Array.isArray(data.outbox) ? data.outbox : {};
1879
- this.legacyMigrations = validDocument && data.legacyMigrations && typeof data.legacyMigrations === "object" && !Array.isArray(data.legacyMigrations)
1880
- ? data.legacyMigrations
1881
- : {};
1882
- }
1883
- // Pre-1.3 bare storage has no endpoint provenance, so it is left intact
1884
- // unless the host explicitly designates this endpoint. Page-qualified
1885
- // legacy keys are only claimed by a toolbar currently on that exact page.
1886
- const migratedKeys = this._migrateUnnamespacedStorage();
1887
- migratedKeys.push(...this._migratePageAnnotations());
1888
- this._normalizeStoredState();
1889
- this._recordLegacyMigrations();
1890
- if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
1891
- this._rebuildList();
1892
- this._updateCount();
1893
- } catch (e) { console.warn("[rails-markup] load failed:", e); }
1894
- },
1895
-
1896
- _migrateUnnamespacedStorage() {
1897
- const sourceKeys = [];
1898
- const designatedEndpoint = (this.legacyStorageEndpoint || "").replace(/\/+$/, "");
1899
- const currentEndpoint = (this.endpoint || "").replace(/\/+$/, "");
1900
- const claimBareStorage = Boolean(designatedEndpoint) && designatedEndpoint === currentEndpoint;
1901
- const currentPageKey = `rm-annotations:${this._pageUrl()}`;
1902
- for (let index = 0; index < localStorage.length; index++) {
1903
- const key = localStorage.key(index);
1904
- if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
1905
- }
1906
-
1907
- const migratedKeys = [];
1908
- const legacyAnnotations = [];
1909
- const legacyOutbox = {};
1910
- const consolidatedClientIds = new Set(
1911
- this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
1912
- );
1913
- sourceKeys.forEach(key => {
1914
- try {
1915
- const data = JSON.parse(localStorage.getItem(key));
1916
- if (!this._plainObject(data)) return;
1917
- const hasAnnotations = Array.isArray(data.annotations);
1918
- const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
1919
- if (!hasAnnotations && !hasOutbox) return;
1920
-
1921
- if (hasAnnotations) {
1922
- data.annotations.forEach((annotation, index) => {
1923
- if (!this._plainObject(annotation)) return;
1924
- const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
1925
- const migratedClientId = this.legacyMigrations[fingerprint];
1926
- if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
1927
- if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
1928
- if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
1929
- Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
1930
- configurable: true,
1931
- value: fingerprint
1932
- });
1933
- legacyAnnotations.push(annotation);
1934
- if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
1935
- });
1936
- }
1937
- if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
1938
- if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
1939
- migratedKeys.push(key);
1940
- } catch {}
1941
- });
1942
-
1943
- this.annotations = legacyAnnotations.concat(this.annotations);
1944
- this.outbox = Object.assign({}, legacyOutbox, this.outbox);
1945
- return migratedKeys;
1946
- },
1947
-
1948
- _migratePageAnnotations() {
1949
- // Find and merge per-page annotation keys only within this endpoint namespace.
1950
- const prefix = this._storageKey() + ":";
1951
- const migratedKeys = [];
1952
- const seenIds = new Set(this.annotations.map(a => a.id));
1953
- const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
1954
- for (let i = 0; i < localStorage.length; i++) {
1955
- const key = localStorage.key(i);
1956
- if (key && key.startsWith(prefix)) {
1957
- try {
1958
- const data = JSON.parse(localStorage.getItem(key));
1959
- if (data && Array.isArray(data.annotations)) {
1960
- data.annotations.forEach((a, index) => {
1961
- const fingerprint = this._legacyMigrationFingerprint(key, index, a);
1962
- const migratedClientId = this.legacyMigrations[fingerprint];
1963
- if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
1964
- if (this._validClientId(migratedClientId)) a.clientId = migratedClientId;
1965
- Object.defineProperty(a, "_legacyMigrationFingerprint", { configurable: true, value: fingerprint });
1966
- // Legacy ids were per-page counters, so different pages can reuse
1967
- // the same id. Reassign a fresh id on collision instead of
1968
- // dropping the annotation (which silently lost data).
1969
- if (seenIds.has(a.id)) a.id = this.nextId++;
1970
- seenIds.add(a.id);
1971
- this.annotations.push(a);
1972
- if (a.id >= this.nextId) this.nextId = a.id + 1;
1973
- });
1974
- migratedKeys.push(key);
1975
- }
1976
- } catch {}
1977
- }
1978
- }
1979
- return migratedKeys;
1980
- },
1981
-
1982
- _legacyMigrationFingerprint(key, index, annotation) {
1983
- const input = `${key}\u0000${index}\u0000${JSON.stringify(annotation)}`;
1984
- let hash = 2166136261;
1985
- for (let i = 0; i < input.length; i++) hash = Math.imul(hash ^ input.charCodeAt(i), 16777619);
1986
- return `${key}:${index}:${(hash >>> 0).toString(16)}`;
1987
- },
1988
-
1989
- _recordLegacyMigrations() {
1990
- this.annotations.forEach(annotation => {
1991
- if (!annotation._legacyMigrationFingerprint) return;
1992
- this.legacyMigrations[annotation._legacyMigrationFingerprint] = annotation.clientId;
1993
- delete annotation._legacyMigrationFingerprint;
1994
- });
1995
- },
1996
-
1997
- _cleanupMigratedKeys(keys) {
1998
- keys.forEach(key => {
1999
- try { localStorage.removeItem(key); }
2000
- catch (e) { console.warn("[rails-markup] legacy cleanup failed:", e); }
2001
- });
2002
- },
2003
-
2004
- _normalizeStoredState() {
2005
- if (!this.outbox || typeof this.outbox !== "object" || Array.isArray(this.outbox)) this.outbox = {};
2006
-
2007
- const usedClientIds = new Set(this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId)));
2008
- const invalidOutboxOwners = this._invalidOutboxOwners();
2009
- const normalized = this.annotations.map((annotation, index) => {
2010
- const originalClientId = annotation.clientId;
2011
- if (!this._validClientId(originalClientId)) {
2012
- let replacementClientId;
2013
- do { replacementClientId = this._newClientId(); } while (usedClientIds.has(replacementClientId));
2014
- usedClientIds.add(replacementClientId);
2015
- annotation.clientId = replacementClientId;
2016
- if (invalidOutboxOwners.get(originalClientId) === annotation) this._rekeyOutbox(originalClientId, replacementClientId);
2017
- }
2018
- if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
2019
- if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
2020
- if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
2021
- annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
2022
- ? annotation.server_revision
2023
- : 0;
2024
- }
2025
- if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
2026
- annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
2027
- annotation.pathname = annotation.pageUrl;
2028
-
2029
- return { annotation, index };
2030
- });
2031
-
2032
- this._normalizeOutboxEnvelopes();
2033
-
2034
- const byClientId = new Map();
2035
- normalized.forEach(candidate => {
2036
- const current = byClientId.get(candidate.annotation.clientId);
2037
- if (!current || this._isNewerLocalRecord(candidate, current)) byClientId.set(candidate.annotation.clientId, candidate);
2038
- });
2039
- this.annotations = Array.from(byClientId.values())
2040
- .sort((left, right) => left.index - right.index)
2041
- .map(candidate => candidate.annotation);
2042
-
2043
- this._assignDisplayIds();
2044
- this.annotations.forEach(annotation => {
2045
- const mapped = annotation.serverId != null;
2046
- const queuedEntry = this.outbox[annotation.clientId];
2047
- const queued = Boolean(queuedEntry);
2048
- const annotationRevision = Number.isInteger(annotation.revision) && annotation.revision >= 0 ? annotation.revision : 0;
2049
- annotation.revision = annotationRevision;
2050
- annotation.syncState = (queued && annotation.syncState === "failed")
2051
- ? "failed"
2052
- : ((queued || !mapped) ? "pending" : "synced");
2053
- if (!this.outbox[annotation.clientId] && !mapped) {
2054
- annotation.dirtyFields = this._legacyDirtyFields(annotation);
2055
- this.outbox[annotation.clientId] = {
2056
- type: "upsert",
2057
- clientId: annotation.clientId,
2058
- revision: annotation.revision,
2059
- baseRevision: annotation.serverRevision,
2060
- syncState: "pending",
2061
- annotation: this._desiredState(annotation),
2062
- dirtyFields: annotation.dirtyFields.slice()
2063
- };
2064
- }
2065
- });
2066
- },
2067
-
2068
- _normalizeOutboxEnvelopes() {
2069
- const normalized = {};
2070
-
2071
- Object.entries(this.outbox).forEach(([storedClientId, candidate]) => {
2072
- if (!this._plainObject(candidate)) return;
2073
-
2074
- const nestedClientId = candidate.annotation?.clientId;
2075
- const clientId = this._validClientId(nestedClientId)
2076
- ? nestedClientId
2077
- : (this._validClientId(candidate.clientId)
2078
- ? candidate.clientId
2079
- : (this._validClientId(storedClientId) ? storedClientId : null));
2080
- if (!clientId) return;
2081
-
2082
- const type = candidate.type === "delete"
2083
- ? "delete"
2084
- : ((candidate.type === "upsert" || this._plainObject(candidate.annotation)) ? "upsert" : null);
2085
- if (!type) return;
2086
-
2087
- const annotation = this.annotations.find(record => record.clientId === clientId);
2088
- const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
2089
- const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
2090
- const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
2091
- ? candidate.baseRevision
2092
- : 0;
2093
- const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
2094
- ? annotation.serverRevision
2095
- : 0;
2096
- const envelope = Object.assign({}, candidate, {
2097
- type,
2098
- clientId,
2099
- revision: Math.max(candidateRevision, annotationRevision),
2100
- baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
2101
- syncState: candidate.syncState === "failed" ? "failed" : "pending"
2102
- });
2103
-
2104
- if (type === "upsert") {
2105
- envelope.annotation = Object.assign({}, candidate.annotation, { clientId });
2106
- envelope.dirtyFields = this._mergeDirtyFields(candidate.dirtyFields || envelope.annotation.dirtyFields || []);
2107
- }
2108
- normalized[clientId] = envelope;
2109
- });
2110
-
2111
- this.outbox = normalized;
2112
- },
2113
-
2114
- _isNewerLocalRecord(candidate, current) {
2115
- const timestamp = value => {
2116
- const parsed = Date.parse(value || "");
2117
- return Number.isNaN(parsed) ? -Infinity : parsed;
2118
- };
2119
- const candidateUpdatedAt = timestamp(candidate.annotation.updatedAt || candidate.annotation.updated_at);
2120
- const currentUpdatedAt = timestamp(current.annotation.updatedAt || current.annotation.updated_at);
2121
- if (candidateUpdatedAt !== currentUpdatedAt) return candidateUpdatedAt > currentUpdatedAt;
2122
- return candidate.index > current.index;
2123
- },
2124
-
2125
- _assignDisplayIds() {
2126
- const validIds = this.annotations
2127
- .map(annotation => annotation.id)
2128
- .filter(id => Number.isInteger(id) && id > 0);
2129
- let candidateId = Math.max(1, Number.isInteger(this.nextId) ? this.nextId : 1, ...validIds.map(id => id + 1));
2130
- const usedIds = new Set();
2131
-
2132
- this.annotations.forEach(annotation => {
2133
- if (!Number.isInteger(annotation.id) || annotation.id <= 0 || usedIds.has(annotation.id)) {
2134
- while (usedIds.has(candidateId)) candidateId += 1;
2135
- annotation.id = candidateId++;
2136
- }
2137
- usedIds.add(annotation.id);
2138
- });
2139
-
2140
- this.nextId = Math.max(candidateId, ...Array.from(usedIds, id => id + 1), 1);
2141
- },
2142
-
2143
- _legacyDirtyFields(annotation) {
2144
- const fields = this._browserCreateFields();
2145
- if (annotation.status && annotation.status !== "pending") fields.push("status");
2146
- return fields;
2147
- },
2148
-
2149
- _browserCreateFields() {
2150
- return ["content", "intent", "severity", "selected_text", "target", "page_url", "metadata"];
2151
- },
2152
-
2153
- _desiredState(annotation) {
2154
- const sourceMetadata = annotation.metadata && typeof annotation.metadata === "object" ? annotation.metadata : {};
2155
- const metadata = {};
2156
- ["tool", "url", "localId", "sessionId", "screenshot"].forEach(key => {
2157
- if (sourceMetadata[key] != null) metadata[key] = sourceMetadata[key];
2158
- });
2159
- if (metadata.tool == null) metadata.tool = "rails-markup";
2160
- if (metadata.url == null && annotation.url) metadata.url = annotation.url;
2161
- if (metadata.localId == null && annotation.id != null) metadata.localId = annotation.id;
2162
- if (metadata.sessionId == null && this.sessionId != null) metadata.sessionId = this.sessionId;
2163
- if (metadata.screenshot == null && annotation.screenshot) metadata.screenshot = annotation.screenshot;
2164
-
2165
- return JSON.parse(JSON.stringify({
2166
- clientId: annotation.clientId,
2167
- page_url: annotation.pageUrl || annotation.pathname || this._pageUrl(),
2168
- content: annotation.comment ?? annotation.content ?? "",
2169
- intent: annotation.intent,
2170
- severity: annotation.severity,
2171
- selected_text: annotation.selectedText ?? annotation.selected_text ?? null,
2172
- target: annotation.element || annotation.target || {},
2173
- metadata,
2174
- status: annotation.status || "pending"
2175
- }));
2176
- },
2177
-
2178
- _validClientId(clientId) {
2179
- return typeof clientId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(clientId);
2180
- },
2181
-
2182
- _invalidOutboxOwners() {
2183
- const owners = new Map();
2184
- this.annotations.forEach(annotation => {
2185
- const clientId = annotation.clientId;
2186
- if (!clientId || this._validClientId(clientId) || owners.has(clientId) || !this.outbox[clientId]) return;
2187
-
2188
- const peers = this.annotations.filter(candidate => candidate.clientId === clientId);
2189
- const desired = this.outbox[clientId].annotation || this.outbox[clientId];
2190
- const desiredId = desired.id ?? desired.metadata?.localId;
2191
- const desiredComment = desired.comment ?? desired.content;
2192
- const owner = peers.find(candidate => desiredId != null && candidate.id === desiredId)
2193
- || peers.find(candidate => desiredComment != null && (candidate.comment ?? candidate.content) === desiredComment)
2194
- || peers[0];
2195
- owners.set(clientId, owner);
2196
- });
2197
- return owners;
2198
- },
2199
-
2200
- _rekeyOutbox(oldClientId, newClientId) {
2201
- if (!oldClientId || !this.outbox[oldClientId]) return;
2202
- const entry = this.outbox[oldClientId];
2203
- delete this.outbox[oldClientId];
2204
- if (entry.annotation) entry.annotation.clientId = newClientId;
2205
- if (entry.clientId) entry.clientId = newClientId;
2206
- this.outbox[newClientId] = entry;
2207
- },
2208
-
2209
- _newClientId() {
2210
- if (global.crypto && typeof global.crypto.randomUUID === "function") return global.crypto.randomUUID();
2211
- const bytes = new Uint8Array(16);
2212
- if (global.crypto && typeof global.crypto.getRandomValues === "function") global.crypto.getRandomValues(bytes);
2213
- else bytes.forEach((_, index) => { bytes[index] = Math.floor(Math.random() * 256); });
2214
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
2215
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
2216
- const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
2217
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
2218
- },
2219
-
2220
- // ---- Server sync ----
2221
-
2222
- _pullAnnotations() {
2223
- const pageUrl = this._pageUrl();
2224
- if (this._pullPromise && this._pullPageUrl === pageUrl) return this._pullPromise;
2225
-
2226
- const previousPull = this._pullPromise;
2227
- const operation = previousPull
2228
- ? previousPull.then(() => this._performAnnotationPull(pageUrl))
2229
- : this._performAnnotationPull(pageUrl);
2230
- let trackedPull;
2231
- trackedPull = operation.finally(() => {
2232
- if (this._pullPromise !== trackedPull) return;
2233
- this._pullPromise = null;
2234
- this._pullPageUrl = null;
2235
- });
2236
- this._pullPageUrl = pageUrl;
2237
- this._pullPromise = trackedPull;
2238
- return trackedPull;
2239
- },
2240
-
2241
- _performAnnotationPull(pageUrl) {
2242
- return this._runAnnotationPull(pageUrl)
2243
- .then(complete => {
2244
- this._pullNeeded = !complete;
2245
- return complete;
2246
- })
2247
- .catch(() => {
2248
- this._pullNeeded = true;
2249
- return false;
2250
- });
2251
- },
2252
-
2253
- async _runAnnotationPull(pageUrl) {
2254
- const url = this._sameOriginEndpointUrl(`/annotations?page_url=${encodeURIComponent(pageUrl)}`);
2255
- if (!url) {
2256
- this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
2257
- return false;
2258
- }
2259
-
2260
- const response = await fetch(url, {
2261
- method: "GET",
2262
- headers: { "Accept": "application/json" },
2263
- credentials: "same-origin",
2264
- redirect: "manual",
2265
- signal: AbortSignal.timeout(5000)
2266
- });
2267
- if (!response.ok || response.status >= 300 || response.type === "opaqueredirect") return false;
2268
- const contentType = response.headers.get("Content-Type") || "";
2269
- if (!contentType.toLowerCase().includes("application/json")) return false;
2270
-
2271
- let records;
2272
- try { records = await response.json(); }
2273
- catch { return false; }
2274
- if (pageUrl !== this._pageUrl() || !this._validPullRecords(records, pageUrl)) return false;
2275
- return this._reconcilePull(pageUrl, records);
2276
- },
2277
-
2278
- _validPullRecords(records, pageUrl) {
2279
- if (!Array.isArray(records)) return false;
2280
- const clientIds = new Set();
2281
- return records.every(record => {
2282
- const clientId = record?.clientId;
2283
- if (!this._validClientId(clientId) || clientIds.has(clientId)) return false;
2284
- clientIds.add(clientId);
2285
- return record.pageUrl === pageUrl && this._validServerAnnotation(record, clientId);
2286
- });
2287
- },
2288
-
2289
- _reconcilePull(pageUrl, records) {
2290
- const committed = this._commitLocalStateChange(() => {
2291
- const presentClientIds = new Set(records.map(record => record.clientId));
2292
- records.forEach(server => {
2293
- const outboxEntry = this.outbox[server.clientId];
2294
- if (outboxEntry?.type === "delete") return;
2295
-
2296
- const annotation = this.annotations.find(candidate => candidate.clientId === server.clientId);
2297
- if (annotation) this._mergePulledAnnotation(annotation, server, outboxEntry);
2298
- else this.annotations.push(this._annotationFromServer(server));
2299
- });
2300
-
2301
- this.annotations = this.annotations.filter(annotation => {
2302
- const annotationPage = annotation.pageUrl || annotation.pathname;
2303
- if (annotationPage !== pageUrl || presentClientIds.has(annotation.clientId)) return true;
2304
- return Boolean(this.outbox[annotation.clientId]) || annotation.syncState !== "synced";
2305
- });
2306
-
2307
- this._assignDisplayIds();
2308
- Object.entries(this.outbox).forEach(([clientId, entry]) => {
2309
- if (entry?.type !== "upsert") return;
2310
- const annotation = this.annotations.find(candidate => candidate.clientId === clientId);
2311
- if (!annotation) return;
2312
- entry.annotation = this._desiredState(annotation);
2313
- entry.dirtyFields = (annotation.dirtyFields || []).slice();
2314
- entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
2315
- });
2316
- });
2317
- if (!committed) return false;
2318
- this._renderPins();
2319
- this._rebuildList();
2320
- this._updateCount();
2321
- return true;
2322
- },
2323
-
2324
- _mergePulledAnnotation(annotation, server, outboxEntry) {
2325
- if (this._serverRepresentationIsStale(annotation, server)) return;
2326
- const dirtyFields = new Set(this._mergeDirtyFields(annotation.dirtyFields || [], outboxEntry?.dirtyFields || []));
2327
- const browserFields = [
2328
- ["content", "comment"],
2329
- ["intent", "intent"],
2330
- ["severity", "severity"],
2331
- ["selected_text", "selectedText"],
2332
- ["target", "element"],
2333
- ["metadata", "metadata"],
2334
- ["status", "status"]
2335
- ];
2336
- browserFields.forEach(([dirtyField, localField]) => {
2337
- if (dirtyFields.has(dirtyField)) return;
2338
- const serverField = {
2339
- content: "content", selected_text: "selectedText", target: "target"
2340
- }[dirtyField] || dirtyField;
2341
- annotation[localField] = this._immutableCopy(server[serverField]);
2342
- });
2343
- if (!dirtyFields.has("page_url")) {
2344
- annotation.pageUrl = server.pageUrl;
2345
- annotation.pathname = server.pageUrl;
2346
- }
2347
- annotation.serverId = server.id;
2348
- annotation.serverRevision = server.revision;
2349
- annotation.userId = server.userId;
2350
- annotation.authorName = server.authorName;
2351
- annotation.createdAt = server.createdAt;
2352
- annotation.serverUpdatedAt = server.updatedAt;
2353
- annotation.thread = this._immutableCopy(server.thread);
2354
- annotation.dirtyFields = Array.from(dirtyFields);
2355
- annotation.syncState = outboxEntry ? (outboxEntry.syncState || "pending") : "synced";
2356
- if (!dirtyFields.has("metadata") && server.metadata?.url) annotation.url = server.metadata.url;
2357
- },
2358
-
2359
- _annotationFromServer(server) {
2360
- return {
2361
- id: null,
2362
- clientId: server.clientId,
2363
- serverId: server.id,
2364
- serverRevision: server.revision,
2365
- userId: server.userId,
2366
- authorName: server.authorName,
2367
- syncState: "synced",
2368
- serverUpdatedAt: server.updatedAt,
2369
- dirtyFields: [],
2370
- revision: 0,
2371
- comment: server.content,
2372
- intent: server.intent,
2373
- severity: server.severity,
2374
- status: server.status,
2375
- selectedText: server.selectedText,
2376
- element: this._immutableCopy(server.target),
2377
- metadata: this._immutableCopy(server.metadata),
2378
- pathname: server.pageUrl,
2379
- pageUrl: server.pageUrl,
2380
- url: server.metadata?.url || server.pageUrl,
2381
- thread: this._immutableCopy(server.thread),
2382
- createdAt: server.createdAt
2383
- };
2384
- },
2385
-
2386
- _serverRepresentationIsStale(annotation, server) {
2387
- if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
2388
- return server.revision < annotation.serverRevision;
2389
- }
2390
- const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
2391
- const serverTimestamp = Date.parse(server.updatedAt || "");
2392
- return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
2393
- },
2394
-
2395
- _onVisibilityChange() {
2396
- if (document.hidden) {
2397
- // Tab hidden — pause health checks to save resources
2398
- if (this.healthInterval) { clearInterval(this.healthInterval); this.healthInterval = null; }
2399
- } else {
2400
- // Tab visible — resume health checks immediately
2401
- if (!this.healthInterval) {
2402
- this._checkHealth();
2403
- this.healthInterval = setInterval(() => this._checkHealth(), this.healthIntervalMs);
2404
- }
2405
- }
2406
- },
2407
-
2408
- _onOnline() {
2409
- return this._checkHealth();
2410
- },
2411
-
2412
- _checkHealth() {
2413
- if (this._healthPromise) return this._healthPromise;
2414
- this._healthPromise = this._runHealthCheck().finally(() => { this._healthPromise = null; });
2415
- return this._healthPromise;
2416
- },
2417
-
2418
- async _runHealthCheck() {
2419
- try {
2420
- const healthUrl = this._sameOriginEndpointUrl("/health");
2421
- if (!healthUrl) {
2422
- this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
2423
- return;
2424
- }
2425
- const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) });
2426
- const was = this.serverOnline;
2427
- this.serverOnline = resp.ok;
2428
- this._updateStatus();
2429
- if (!was && this.serverOnline) await this._initSession();
2430
- if (this.serverOnline && (!was || this._pullNeeded)) await this._pullAnnotations();
2431
- if (this.serverOnline) await this._flushOutbox();
2432
- } catch {
2433
- this.serverOnline = false;
2434
- this._updateStatus();
2435
- }
2436
- },
2437
-
2438
- async _initSession() {
2439
- if (!this.serverOnline) return;
2440
- try {
2441
- const request = this._sameOriginMutationRequest("/sessions");
2442
- if (!request) {
2443
- this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
2444
- return;
2445
- }
2446
- const resp = await fetch(request.url, {
2447
- method: "POST", headers: request.headers, credentials: "same-origin",
2448
- body: JSON.stringify({ url: window.location.href, metadata: { tool: "rails-markup" } }),
2449
- signal: AbortSignal.timeout(5000)
2450
- });
2451
- if (resp.ok) {
2452
- const data = await resp.json();
2453
- this.sessionId = data.id;
2454
- }
2455
- } catch (e) { console.warn("[rails-markup] session init failed:", e); }
2456
- },
2457
-
2458
- async _synchronizeCurrentPage(initializeSession) {
2459
- if (!this.serverOnline) return;
2460
- if (initializeSession) await this._initSession();
2461
- await this._pullAnnotations();
2462
- await this._flushOutbox();
2463
- },
2464
-
2465
- async _pushToServer(annotation) {
2466
- if (!this.serverOnline) return;
2467
- try {
2468
- const request = this._sameOriginMutationRequest(`/sessions/${encodeURIComponent(this.sessionId || "local")}/annotations`);
2469
- if (!request) {
2470
- this._setSyncUnavailable("Sync unavailable: endpoint must be same-origin");
2471
- return;
2472
- }
2473
- await fetch(request.url, {
2474
- method: "POST", headers: request.headers, credentials: "same-origin",
2475
- body: JSON.stringify({
2476
- page_url: this._pageUrl(),
2477
- clientId: annotation.clientId,
2478
- content: annotation.comment,
2479
- intent: annotation.intent,
2480
- severity: annotation.severity,
2481
- selected_text: annotation.selectedText || null,
2482
- target: annotation.element || {},
2483
- metadata: Object.assign(
2484
- { localId: annotation.id, url: annotation.url },
2485
- annotation.screenshot ? { screenshot: annotation.screenshot } : {}
2486
- )
2487
- }),
2488
- signal: AbortSignal.timeout(5000)
2489
- });
2490
- } catch (e) { console.warn("[rails-markup] push failed:", e); }
2491
- },
2492
-
2493
- _updateStatus() {
2494
- const dot = document.getElementById("rm-status-dot");
2495
- const text = document.getElementById("rm-status-text");
2496
- if (dot) {
2497
- dot.style.background = this.serverOnline ? "#4ade80" : "#d1d5db";
2498
- dot.style.boxShadow = this.serverOnline ? "0 0 0 2px rgba(74,222,128,0.2)" : "";
2499
- }
2500
- if (text) text.textContent = this.serverOnline ? "Connected" : (this._syncUnavailable || "Offline");
2501
- },
2502
-
2503
- // ---- Screenshot capture ----
2504
-
2505
- async _captureElement(element) {
2506
- try {
2507
- const rect = element.getBoundingClientRect();
2508
- const width = Math.min(Math.round(rect.width), 800);
2509
- const height = Math.min(Math.round(rect.height), 600);
2510
- if (width < 10 || height < 10) return null;
2511
-
2512
- const clone = element.cloneNode(true);
2513
- // Strip scripts and event handlers
2514
- clone.querySelectorAll("script").forEach(s => s.remove());
2515
- // Remove cross-origin images to avoid tainting the canvas
2516
- const origin = location.origin;
2517
- clone.querySelectorAll("img").forEach(img => {
2518
- try { if (img.src && !img.src.startsWith(origin) && !img.src.startsWith("data:")) img.removeAttribute("src"); } catch {}
2519
- });
2520
-
2521
- const svgNS = "http://www.w3.org/2000/svg";
2522
- const svg = `<svg xmlns="${svgNS}" width="${width}" height="${height}">
2523
- <foreignObject width="100%" height="100%">
2524
- <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;">${clone.outerHTML}</div>
2525
- </foreignObject>
2526
- </svg>`;
2527
-
2528
- const canvas = document.createElement("canvas");
2529
- const scale = Math.min(window.devicePixelRatio || 1, 2);
2530
- canvas.width = width * scale;
2531
- canvas.height = height * scale;
2532
- const ctx = canvas.getContext("2d");
2533
- ctx.scale(scale, scale);
2534
-
2535
- const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
2536
- const url = URL.createObjectURL(blob);
2537
-
2538
- return new Promise((resolve) => {
2539
- const img = new Image();
2540
- img.onload = () => {
2541
- ctx.drawImage(img, 0, 0, width, height);
2542
- URL.revokeObjectURL(url);
2543
- try { resolve(canvas.toDataURL("image/png", 0.7)); } catch { resolve(null); }
2544
- };
2545
- img.onerror = () => {
2546
- URL.revokeObjectURL(url);
2547
- resolve(null);
2548
- };
2549
- img.src = url;
2550
- });
2551
- } catch {
2552
- return null;
2553
- }
2554
- },
2555
-
2556
- // ---- Drawing tools ----
2557
-
2558
- _initDrawing(screenshotDataUrl) {
2559
- const popup = document.getElementById("rm-popup");
2560
- let container = popup.querySelector(".rm-drawing-container");
2561
- if (container) container.remove();
2562
-
2563
- container = document.createElement("div");
2564
- container.className = "rm-drawing-container";
2565
- container.style.cssText = "position:relative;margin-bottom:12px;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb;";
2566
-
2567
- const img = new Image();
2568
- img.src = screenshotDataUrl;
2569
- img.style.cssText = "display:block;max-width:100%;border-radius:8px;";
2570
- container.appendChild(img);
2571
-
2572
- const canvas = document.createElement("canvas");
2573
- canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;";
2574
- container.appendChild(canvas);
2575
-
2576
- // Tool buttons
2577
- const tools = document.createElement("div");
2578
- tools.style.cssText = "display:flex;gap:4px;padding:6px 0;";
2579
- tools.innerHTML = `
2580
- <button type="button" data-draw="arrow" class="rm-pill" style="font-size:11px;padding:4px 10px;">Arrow</button>
2581
- <button type="button" data-draw="rect" class="rm-pill" style="font-size:11px;padding:4px 10px;">Rect</button>
2582
- <button type="button" data-draw="highlight" class="rm-pill" style="font-size:11px;padding:4px 10px;">Highlight</button>
2583
- <button type="button" data-draw="undo" class="rm-pill" style="font-size:11px;padding:4px 10px;margin-left:auto;">Undo</button>
2584
- `;
2585
-
2586
- const textarea = popup.querySelector("textarea");
2587
- popup.insertBefore(tools, textarea);
2588
- popup.insertBefore(container, tools);
2589
-
2590
- img.onload = () => {
2591
- canvas.width = img.naturalWidth || img.offsetWidth;
2592
- canvas.height = img.naturalHeight || img.offsetHeight;
2593
- this.drawingCanvas = canvas;
2594
- this.drawingCtx = canvas.getContext("2d");
2595
- this._screenshotImg = img;
2596
- this.drawingHistory = [];
2597
- this.drawingMode = null;
2598
- this._bindDrawingEvents(canvas, tools);
2599
- };
2600
- },
2601
-
2602
- _bindDrawingEvents(canvas, tools) {
2603
- const self = this;
2604
- let isDrawing = false;
2605
- let points = [];
2606
-
2607
- tools.addEventListener("click", (e) => {
2608
- const btn = e.target.closest("[data-draw]");
2609
- if (!btn) return;
2610
- const mode = btn.dataset.draw;
2611
- if (mode === "undo") {
2612
- self._undoDrawing();
2613
- return;
2614
- }
2615
- self.drawingMode = mode;
2616
- tools.querySelectorAll("[data-draw]").forEach(b => {
2617
- b.style.background = b.dataset.draw === mode ? self._accentBg() : "#fff";
2618
- b.style.color = b.dataset.draw === mode ? "#fff" : "#374151";
2619
- });
2620
- });
2621
-
2622
- const getPos = (e) => {
2623
- const rect = canvas.getBoundingClientRect();
2624
- return {
2625
- x: (e.clientX - rect.left) * (canvas.width / rect.width),
2626
- y: (e.clientY - rect.top) * (canvas.height / rect.height)
2627
- };
2628
- };
2629
-
2630
- canvas.addEventListener("mousedown", (e) => {
2631
- if (!self.drawingMode) return;
2632
- e.stopPropagation();
2633
- isDrawing = true;
2634
- self.drawingStart = getPos(e);
2635
- points = [self.drawingStart];
2636
- });
2637
-
2638
- canvas.addEventListener("mousemove", (e) => {
2639
- if (!isDrawing || !self.drawingMode) return;
2640
- e.stopPropagation();
2641
- const pos = getPos(e);
2642
- if (self.drawingMode === "highlight") {
2643
- points.push(pos);
2644
- self._redrawCanvas();
2645
- self._drawHighlightPath(points);
2646
- } else {
2647
- self._redrawCanvas();
2648
- self._drawShape(self.drawingMode, self.drawingStart, pos);
2649
- }
2650
- });
2651
-
2652
- canvas.addEventListener("mouseup", (e) => {
2653
- if (!isDrawing || !self.drawingMode) return;
2654
- e.stopPropagation();
2655
- isDrawing = false;
2656
- const end = getPos(e);
2657
- if (self.drawingMode === "highlight") {
2658
- self.drawingHistory.push({ type: "highlight", points: [...points] });
2659
- } else {
2660
- self.drawingHistory.push({ type: self.drawingMode, start: self.drawingStart, end: end });
2661
- }
2662
- self._redrawCanvas();
2663
- points = [];
2664
- });
2665
- },
2666
-
2667
- _drawShape(type, start, end) {
2668
- const ctx = this.drawingCtx;
2669
- if (!ctx) return;
2670
- ctx.lineWidth = 3;
2671
-
2672
- if (type === "arrow") {
2673
- ctx.strokeStyle = "#ef4444";
2674
- ctx.beginPath();
2675
- ctx.moveTo(start.x, start.y);
2676
- ctx.lineTo(end.x, end.y);
2677
- ctx.stroke();
2678
- // Arrowhead
2679
- const angle = Math.atan2(end.y - start.y, end.x - start.x);
2680
- const headLen = 12;
2681
- ctx.beginPath();
2682
- ctx.moveTo(end.x, end.y);
2683
- ctx.lineTo(end.x - headLen * Math.cos(angle - Math.PI / 6), end.y - headLen * Math.sin(angle - Math.PI / 6));
2684
- ctx.moveTo(end.x, end.y);
2685
- ctx.lineTo(end.x - headLen * Math.cos(angle + Math.PI / 6), end.y - headLen * Math.sin(angle + Math.PI / 6));
2686
- ctx.stroke();
2687
- } else if (type === "rect") {
2688
- ctx.strokeStyle = "#ef4444";
2689
- ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
2690
- }
2691
- },
2692
-
2693
- _drawHighlightPath(points) {
2694
- const ctx = this.drawingCtx;
2695
- if (!ctx || points.length < 2) return;
2696
- ctx.strokeStyle = "rgba(250,204,21,0.5)";
2697
- ctx.lineWidth = 16;
2698
- ctx.lineCap = "round";
2699
- ctx.lineJoin = "round";
2700
- ctx.beginPath();
2701
- ctx.moveTo(points[0].x, points[0].y);
2702
- for (let i = 1; i < points.length; i++) {
2703
- ctx.lineTo(points[i].x, points[i].y);
2704
- }
2705
- ctx.stroke();
2706
- ctx.lineWidth = 3;
2707
- },
2708
-
2709
- _redrawCanvas() {
2710
- const ctx = this.drawingCtx;
2711
- if (!ctx) return;
2712
- ctx.clearRect(0, 0, this.drawingCanvas.width, this.drawingCanvas.height);
2713
- this.drawingHistory.forEach(shape => {
2714
- if (shape.type === "highlight") {
2715
- this._drawHighlightPath(shape.points);
2716
- } else {
2717
- this._drawShape(shape.type, shape.start, shape.end);
2718
- }
2719
- });
2720
- },
2721
-
2722
- _undoDrawing() {
2723
- if (this.drawingHistory.length === 0) return;
2724
- this.drawingHistory.pop();
2725
- this._redrawCanvas();
2726
- },
2727
-
2728
- _mergeDrawing() {
2729
- if (!this.drawingCanvas || !this._screenshotImg || this.drawingHistory.length === 0) return null;
2730
- try {
2731
- const merged = document.createElement("canvas");
2732
- merged.width = this.drawingCanvas.width;
2733
- merged.height = this.drawingCanvas.height;
2734
- const ctx = merged.getContext("2d");
2735
- ctx.drawImage(this._screenshotImg, 0, 0, merged.width, merged.height);
2736
- ctx.drawImage(this.drawingCanvas, 0, 0);
2737
- return merged.toDataURL("image/png", 0.7);
2738
- } catch {
2739
- return null;
2740
- }
2741
- },
2742
-
2743
- // ---- Size helpers ----
2744
-
2745
- _fabIconSize() {
2746
- const map = { "default": 20, compact: 18, slim: 16 };
2747
- return map[this.size] || 20;
2748
- },
2749
-
2750
- // ---- Color helpers ----
2751
-
2752
- _accentBg() {
2753
- const map = { indigo: "#4f46e5", amber: "#f59e0b", blue: "#2563eb", emerald: "#059669", rose: "#e11d48" };
2754
- return map[this.accent] || map.indigo;
2755
- },
2756
- _accentBgHover() {
2757
- const map = { indigo: "#4338ca", amber: "#d97706", blue: "#1d4ed8", emerald: "#047857", rose: "#be123c" };
2758
- return map[this.accent] || map.indigo;
2759
- },
2760
- _accentLight() {
2761
- const map = { indigo: "#e0e7ff", amber: "#fef3c7", blue: "#dbeafe", emerald: "#d1fae5", rose: "#ffe4e6" };
2762
- return map[this.accent] || map.indigo;
2763
- },
2764
- _accentText() {
2765
- const map = { indigo: "#3730a3", amber: "#92400e", blue: "#1e40af", emerald: "#065f46", rose: "#9f1239" };
2766
- return map[this.accent] || map.indigo;
2767
- },
2768
-
2769
- // ---- Helpers ----
2770
-
2771
- _esc(str) {
2772
- const div = document.createElement("div");
2773
- div.textContent = str || "";
2774
- return div.innerHTML;
2775
- }
2776
- };
2777
-
2778
- global.RailsMarkupToolbar = RailsMarkupToolbar;
2779
- })(typeof window !== "undefined" ? window : this);