@comty.io/core 0.1.3 → 0.1.5

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.
package/src/browser.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getComtyStyle } from "./style.js";
2
2
  import { createAnchorMatcher } from "./anchors.js";
3
- import { createScreenshotCapture, createColorMagnifier } from "./capture.js";
3
+ import { createScreenshotCapture, createColorMagnifier, captureContext } from "./capture.js";
4
4
  import { createCommentThread } from "./comments.js";
5
5
  import { createMeasurements } from "./measurements.js";
6
6
  // Shared browser runtime. Both npm and the standalone CDN script use this file.
@@ -57,11 +57,14 @@ export function initComty(options = {}) {
57
57
  const observedCommentTargets = new WeakSet();
58
58
  let sidepanelOpen = false;
59
59
  let canWrite = false;
60
+ let canManage = false;
61
+ let allowGuestComments = false;
60
62
  let loginUrl = "";
61
63
  let currentUser = null;
62
64
  let authStatus = "";
63
65
  let didCompleteSignIn = false;
64
66
  const tokenStorageKey = `comty:${projectKey}:token`;
67
+ const authReturnStorageKey = `comty:${projectKey}:auth-return`;
65
68
  let sdkToken = "";
66
69
  const activeHoverCommentIds = new Set();
67
70
  /** @type {null | 'edit' | 'browse'} */
@@ -70,7 +73,6 @@ export function initComty(options = {}) {
70
73
  const placementDiagnostics = new Map();
71
74
  let inspectedElement = null;
72
75
  let selectedElement = null;
73
- let inspectExpanded = false;
74
76
  let measureAlt = false;
75
77
  let inspectDrag = null;
76
78
  let measuredRegion = null;
@@ -752,6 +754,21 @@ export function initComty(options = {}) {
752
754
  const copyIcon = icon('<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>');
753
755
  const checkIcon = icon('<path d="m5 12 4 4 10-10"/>');
754
756
  const copyFeedbackTimers = new WeakMap();
757
+ async function copyPropertyValue(button, feedback) {
758
+ clearTimeout(copyFeedbackTimers.get(button));
759
+ try {
760
+ await navigator.clipboard.writeText(button.dataset.copy);
761
+ if (!button.isConnected) return;
762
+ button.dataset.copied = "";
763
+ feedback.textContent = "Copied";
764
+ clearTimeout(copyFeedbackTimers.get(button));
765
+ copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
766
+ } catch {
767
+ delete button.dataset.copied;
768
+ if (button.isConnected) feedback.textContent = "Clipboard unavailable — select the value to copy";
769
+ }
770
+ }
771
+
755
772
  function propertyRow(label, value, display = value) {
756
773
  const unavailable = value === "—";
757
774
  return `<div class="comty-property-row"><span>${label}</span><button type="button" data-copy="${escapeInspectorText(value)}" aria-label="Copy ${label}: ${escapeInspectorText(display)}" ${unavailable ? "disabled" : ""}><span>${escapeInspectorText(display)}</span>${unavailable ? "" : `<i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i>`}</button></div>`;
@@ -778,7 +795,7 @@ export function initComty(options = {}) {
778
795
  propertyPanel.setAttribute("aria-label", propertyKind === "color" ? "Recent colours" : "Recent typography");
779
796
  const recent = propertyKind === "color" ? recentColors : recentFonts;
780
797
  const details = propertyKind === "color" && expanded
781
- ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? `var(${propertySelection.token})` : "—")}</div></div>`
798
+ ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? propertySelection.token.replace(/^--/, "") : "—", propertySelection.token || "—")}</div></div>`
782
799
  : propertyKind === "font" && expanded
783
800
  ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("Family", propertySelection.family)}${propertyRow("Size", propertySelection.size)}${propertyRow("Weight", propertySelection.weight)}${propertyRow("Leading", propertySelection.leading)}${propertyRow("Tracking", propertySelection.tracking)}${propertyRow("Colour", propertySelection.color?.hex || "—")}${propertyRow("CSS", propertySelection.css, "All of the above")}</div></div>`
784
801
  : "";
@@ -894,18 +911,7 @@ export function initComty(options = {}) {
894
911
  renderPropertyPanel(true);
895
912
  propertyPanel.querySelector(`button[data-recent="${button.dataset.recent}"]`)?.focus({ preventScroll: true });
896
913
  } else if (button.dataset.copy != null && button.dataset.copy !== "—") {
897
- clearTimeout(copyFeedbackTimers.get(button));
898
- try {
899
- await navigator.clipboard.writeText(button.dataset.copy);
900
- if (!button.isConnected || propertyPanel.hidden) return;
901
- button.dataset.copied = "";
902
- propertyPanel.querySelector(".comty-property-feedback").textContent = `${button.getAttribute("aria-label").slice(5)} copied`;
903
- clearTimeout(copyFeedbackTimers.get(button));
904
- copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
905
- } catch {
906
- delete button.dataset.copied;
907
- if (button.isConnected && !propertyPanel.hidden) propertyPanel.querySelector(".comty-property-feedback").textContent = "Clipboard unavailable";
908
- }
914
+ await copyPropertyValue(button, propertyPanel.querySelector(".comty-property-feedback"));
909
915
  }
910
916
  };
911
917
 
@@ -1402,7 +1408,8 @@ export function initComty(options = {}) {
1402
1408
  : headers;
1403
1409
  }
1404
1410
 
1405
- function showAuthPrompt(forbidden = false) {
1411
+ function showAuthPrompt(forbidden = false, onReady = startEditSession) {
1412
+ const guestAllowed = allowGuestComments;
1406
1413
  document.querySelector(".comty-auth-backdrop")?.remove();
1407
1414
  const opener = document.activeElement;
1408
1415
  const backdrop = document.createElement("div");
@@ -1413,12 +1420,23 @@ export function initComty(options = {}) {
1413
1420
  <span class="comty-auth-icon">${icon('<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 1 1 16.1-3.8Z"/>')}</span>
1414
1421
  <button type="button" class="comty-auth-close" data-comty-close aria-label="Close sign-in dialog">${icon('<path d="m6 6 12 12M6 18 18 6"/>')}</button>
1415
1422
  </div>
1416
- <h2 id="comty-auth-title">${forbidden ? "Project access required" : "Sign in to comment"}</h2>
1417
- <p id="comty-auth-description">${forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
1418
- <div class="comty-auth-actions">
1419
- <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
1420
- ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
1421
- </div>
1423
+ <h2 id="comty-auth-title">${guestAllowed ? "Leave a comment" : forbidden ? "Project access required" : "Sign in to comment"}</h2>
1424
+ <p id="comty-auth-description">${guestAllowed ? "Continue with your name or sign in with Google." : forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
1425
+ ${guestAllowed ? `
1426
+ <form class="comty-guest-form">
1427
+ <label for="comty-guest-name">Your name</label>
1428
+ <input id="comty-guest-name" name="name" autocomplete="name" placeholder="Your name" maxlength="80" required aria-describedby="comty-guest-error" />
1429
+ <p id="comty-guest-error" role="alert" hidden></p>
1430
+ <div class="comty-auth-actions comty-guest-actions">
1431
+ <button type="submit" class="comty-auth-primary" data-comty-guest>Continue</button>
1432
+ <span class="comty-auth-or">or</span>
1433
+ <button type="button" data-comty-login>Continue with Google</button>
1434
+ </div>
1435
+ </form>` : `
1436
+ <div class="comty-auth-actions">
1437
+ <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
1438
+ ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
1439
+ </div>`}
1422
1440
  </div>`;
1423
1441
  const close = () => {
1424
1442
  backdrop.remove();
@@ -1429,11 +1447,12 @@ export function initComty(options = {}) {
1429
1447
  event.stopPropagation();
1430
1448
  if (event.key === "Escape") { event.preventDefault(); close(); }
1431
1449
  if (event.key === "Tab") {
1432
- const buttons = [...backdrop.querySelectorAll("button:not(:disabled)")];
1450
+ const buttons = [...backdrop.querySelectorAll("button:not(:disabled), input:not(:disabled)")];
1433
1451
  if (!buttons.length) { event.preventDefault(); return; }
1434
- const first = buttons[0], last = buttons.at(-1);
1435
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
1436
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
1452
+ event.preventDefault();
1453
+ const index = buttons.indexOf(document.activeElement);
1454
+ const next = (index + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length;
1455
+ buttons[next].focus();
1437
1456
  }
1438
1457
  });
1439
1458
  const loginButton = backdrop.querySelector("[data-comty-login]");
@@ -1441,15 +1460,62 @@ export function initComty(options = {}) {
1441
1460
  loginButton.onclick = () => {
1442
1461
  loginButton.disabled = true;
1443
1462
  loginButton.innerText = "Connecting…";
1444
- backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.disabled = true; });
1445
- setTimeout(() => window.location.assign(loginUrl), 50);
1463
+ backdrop.querySelectorAll("button, input").forEach(control => { control.disabled = true; });
1464
+ try {
1465
+ sessionStorage.setItem(authReturnStorageKey, JSON.stringify({
1466
+ mode: onReady === startEditSession || session.mode === "off" ? "edit" : session.mode,
1467
+ entry: sessionEntry,
1468
+ sidepanelOpen,
1469
+ }));
1470
+ } catch { /* Resume in comment mode if browser storage is unavailable. */ }
1471
+ const destination = new URL("/api/auth/sign-in/google", apiUrl);
1472
+ destination.searchParams.set("callbackURL", loginUrl);
1473
+ setTimeout(() => window.location.assign(guestAllowed ? destination.toString() : loginUrl), 50);
1446
1474
  };
1447
1475
  }
1476
+ const guestForm = backdrop.querySelector(".comty-guest-form");
1477
+ if (guestForm) guestForm.onsubmit = async event => {
1478
+ event.preventDefault();
1479
+ const nameInput = guestForm.querySelector("input");
1480
+ const error = guestForm.querySelector("[role=alert]");
1481
+ const name = nameInput.value.trim();
1482
+ if (!name) { nameInput.focus(); return; }
1483
+ const controls = [...backdrop.querySelectorAll("button, input")];
1484
+ controls.forEach(control => { control.disabled = true; });
1485
+ error.hidden = true;
1486
+ const continueButton = guestForm.querySelector("[data-comty-guest]");
1487
+ continueButton.textContent = "Connecting…";
1488
+ try {
1489
+ const response = await fetch(`${apiUrl}/api/sdk/guest`, {
1490
+ method: "POST",
1491
+ headers: { "Content-Type": "application/json" },
1492
+ body: JSON.stringify({ projectKey, name }),
1493
+ });
1494
+ const data = await response.json();
1495
+ if (!response.ok) throw new Error(data.error || "Unable to continue. Please try again.");
1496
+ sdkToken = data.token;
1497
+ try { sessionStorage.setItem(tokenStorageKey, sdkToken); } catch { /* Keep the session in memory when browser storage is unavailable. */ }
1498
+ currentUser = data.user;
1499
+ canWrite = true;
1500
+ canManage = false;
1501
+ if (backdrop.isConnected) {
1502
+ close();
1503
+ onReady();
1504
+ }
1505
+ } catch (failure) {
1506
+ if (!backdrop.isConnected) return;
1507
+ error.textContent = failure.message || "Unable to connect. Please try again.";
1508
+ error.hidden = false;
1509
+ controls.forEach(control => { control.disabled = false; });
1510
+ continueButton.textContent = "Continue";
1511
+ nameInput.focus();
1512
+ }
1513
+ };
1448
1514
  backdrop.onclick = (event) => {
1449
1515
  if (event.target === backdrop) close();
1450
1516
  };
1451
1517
  root.appendChild(backdrop);
1452
- (loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
1518
+ (backdrop.querySelector("#comty-guest-name") || loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
1453
1519
  }
1454
1520
 
1455
1521
  function formatBoxValue(value) {
@@ -1646,7 +1712,8 @@ export function initComty(options = {}) {
1646
1712
  add("background", css.backgroundColor, (tokens.background.length > 0 || !["rgba(0, 0, 0, 0)", "rgb(255, 255, 255)"].includes(css.backgroundColor)));
1647
1713
  add("color", css.color);
1648
1714
  if (getElementOwnTextSignature(el)) {
1649
- add("font", `${css.fontSize} / ${css.fontWeight}`);
1715
+ add("font-size", css.fontSize);
1716
+ add("font-weight", css.fontWeight);
1650
1717
  add("font-family", css.fontFamily);
1651
1718
  add("line-height", css.lineHeight);
1652
1719
  add("letter-spacing", css.letterSpacing, css.letterSpacing !== "normal");
@@ -1830,7 +1897,7 @@ export function initComty(options = {}) {
1830
1897
  height: draftBubble.offsetHeight,
1831
1898
  };
1832
1899
  const pinBounds = draftPin?.getBoundingClientRect();
1833
- const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, inspectExpanded, measureAlt, draftBounds, pinBounds, spacing]);
1900
+ const signature = JSON.stringify([selector, rect, hoverRect, rows, !!selected, measureAlt, draftBounds, pinBounds, spacing, innerWidth, innerHeight]);
1834
1901
  if (signature === inspectionSignature) return;
1835
1902
  inspectionSignature = signature;
1836
1903
  inspectionLayer.replaceChildren();
@@ -1856,36 +1923,34 @@ export function initComty(options = {}) {
1856
1923
  const panel = document.createElement("div");
1857
1924
  panel.className = "comty-inspector";
1858
1925
  panel.setAttribute("aria-label", "Element properties");
1859
- if (selected) panel.dataset.selected = "";
1860
- const copyable = (value) => `<button type="button" title="Copy ${escapeInspectorText(value)}" data-copy="${escapeInspectorText(value)}">${escapeInspectorText(value)}</button>`;
1861
- const featured = new Set(["background", "color", "font", "font-family"]);
1862
- const visibleRows = inspectExpanded ? rows : selected ? [...rows.filter(([key]) => featured.has(key)), ...rows.filter(([key]) => !featured.has(key))].slice(0, 6) : rows.slice(0, 6);
1863
- panel.innerHTML = `<div class="comty-inspector-head">${copyable(selector)}${copyable(`${formatBoxValue(rect.width)} × ${formatBoxValue(rect.height)}`)}</div><div class="comty-inspector-details">${visibleRows.map(([key, value, tokens]) => {
1864
- const picker = key === "background" || key === "color" ? "color" : key === "font" || key === "font-family" ? "font" : "";
1865
- const valueButton = picker && selected
1866
- ? `<button type="button" class="comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${picker === "color" ? key + " colour" : "typography"} details">${picker === "color" ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}${escapeInspectorText(value)}</button>`
1867
- : copyable(value);
1868
- return `<div class="comty-inspector-row"><span>${key}</span><div class="comty-inspector-value">${tokens?.length ? `<div class="comty-inspector-tokens">${tokens.map(token => copyable(token)).join("")}</div>` : ""}${valueButton}</div></div>`;
1869
- }).join("")}</div>${rows.length > 6 && selected ? `<button type="button" class="comty-inspector-expand">${inspectExpanded ? "Show less" : `Show ${rows.length - 6} more`}</button>` : ""}`;
1870
- for (const button of panel.querySelectorAll('.comty-inspector-picker[data-picker="color"]')) {
1871
- button.querySelector(".comty-inspector-swatch").style.backgroundColor = getComputedStyle(target)[button.dataset.property === "background" ? "backgroundColor" : "color"];
1926
+ if (selected || draftPreview) panel.dataset.selected = "";
1927
+ const priority = ["background", "color", "font-family", "font-size", "font-weight", "margin", "line-height", "letter-spacing"];
1928
+ const orderedRows = [...rows.filter(([key]) => priority.includes(key)).sort((a, b) => priority.indexOf(a[0]) - priority.indexOf(b[0])), ...rows.filter(([key]) => !priority.includes(key))];
1929
+ const labels = { "font-family": "Font", "font-size": "Size", "font-weight": "Weight", "line-height": "Line height", "letter-spacing": "Spacing", "background": "Background", "color": "Color", "margin": "Margin" };
1930
+ const cssProperty = key => ({ radius: "border-radius", background: "background-color" })[key] || key;
1931
+ const copyValue = (value, tokens) => tokens?.length ? `var(${tokens[0]})` : value;
1932
+ const allProperties = `${selector} {\n${rows.map(([key, value, tokens]) => ` ${cssProperty(key)}: ${copyValue(value, tokens)};`).join("\n")}\n}`;
1933
+ const copyButton = (value, label) => `<button type="button" class="comty-inspector-copy" data-copy="${escapeInspectorText(value)}" aria-label="${escapeInspectorText(label)}" title="${escapeInspectorText(label)}"><i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i></button>`;
1934
+ panel.innerHTML = `<div class="comty-inspector-head"><span class="comty-inspector-selector" dir="rtl" title="${escapeInspectorText(selector)}"><bdi dir="ltr">${escapeInspectorText(selector)}</bdi></span>${copyButton(allProperties, "Copy all properties")}</div><div class="comty-inspector-details">${orderedRows.map(([key, value, tokens]) => {
1935
+ const label = labels[key] || key.replaceAll("-", " ").replace(/^./, c => c.toUpperCase());
1936
+ const isColor = key === "background" || key === "color";
1937
+ const picker = isColor ? "color" : key.startsWith("font-") || ["line-height", "letter-spacing"].includes(key) ? "font" : "";
1938
+ const displayValue = isColor ? colorDetails(value)?.hex || value : value;
1939
+ const valueMarkup = `${isColor ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}<span class="comty-inspector-text">${tokens?.length ? `<span class="comty-inspector-tokens">${tokens.map(escapeInspectorText).join(", ")}</span><span class="comty-inspector-secondary">${escapeInspectorText(displayValue)}</span>` : escapeInspectorText(displayValue)}</span>`;
1940
+ const valueElement = picker && selected
1941
+ ? `<button type="button" class="comty-inspector-value comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${isColor ? key + " colour" : "typography"} details">${valueMarkup}</button>`
1942
+ : `<span class="comty-inspector-value" data-property="${key}">${valueMarkup}</span>`;
1943
+ return `<div class="comty-inspector-row"><span class="comty-inspector-label">${label}</span>${valueElement}${copyButton(tokens?.length ? tokens[0].replace(/^--/, "") : value, `Copy ${label}`)}</div>`;
1944
+ }).join("")}</div><span class="comty-property-feedback" role="status"></span>`;
1945
+ for (const swatch of panel.querySelectorAll('.comty-inspector-swatch')) {
1946
+ const key = swatch.parentElement.dataset.property;
1947
+ swatch.style.backgroundColor = rows.find(([name]) => name === key)?.[1];
1872
1948
  }
1873
1949
  panel.onclick = async (event) => {
1874
1950
  const button = event.target.closest("button");
1875
1951
  if (!button) return;
1876
- if (button.classList.contains("comty-inspector-expand")) {
1877
- inspectExpanded = !inspectExpanded;
1878
- renderInspector();
1879
- } else if (button.dataset.picker) {
1880
- openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
1881
- } else {
1882
- try {
1883
- await navigator.clipboard.writeText(button.dataset.copy);
1884
- button.title = "Copied";
1885
- } catch {
1886
- button.title = "Clipboard unavailable — select the value to copy";
1887
- }
1888
- }
1952
+ if (button.dataset.picker) openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
1953
+ else await copyPropertyValue(button, panel.querySelector(".comty-property-feedback"));
1889
1954
  };
1890
1955
  inspectionLayer.appendChild(panel);
1891
1956
  const bounds = panel.getBoundingClientRect();
@@ -1903,8 +1968,24 @@ export function initComty(options = {}) {
1903
1968
  }
1904
1969
  return;
1905
1970
  }
1906
- panel.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - bounds.width / 2, innerWidth - bounds.width - 8))}px`;
1907
- panel.style.top = `${Math.max(8, Math.min(rect.bottom + 4 + bounds.height <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4, innerHeight - bounds.height - 8))}px`;
1971
+ const inset = 8, gap = 12;
1972
+ const clampX = x => Math.max(inset, Math.min(x, innerWidth - bounds.width - inset));
1973
+ const clampY = y => Math.max(inset, Math.min(y, innerHeight - bounds.height - inset));
1974
+ const x = clampX(rect.left), y = clampY(rect.top);
1975
+ const candidates = [
1976
+ { left: x, top: rect.bottom + gap },
1977
+ { left: rect.right + gap, top: y },
1978
+ { left: rect.left - bounds.width - gap, top: y },
1979
+ { left: x, top: rect.top - bounds.height - gap },
1980
+ ];
1981
+ let position = candidates.find(({ left, top }) => left >= inset && top >= inset && left + bounds.width <= innerWidth - inset && top + bounds.height <= innerHeight - inset);
1982
+ if (!position) {
1983
+ // A viewport-sized target leaves no outside space; minimize the covered area.
1984
+ const overlap = ({ left, top }) => Math.max(0, Math.min(left + bounds.width, rect.right) - Math.max(left, rect.left)) * Math.max(0, Math.min(top + bounds.height, rect.bottom) - Math.max(top, rect.top));
1985
+ position = candidates.map(({ left, top }) => ({ left: clampX(left), top: clampY(top) })).sort((a, b) => overlap(a) - overlap(b))[0];
1986
+ }
1987
+ panel.style.left = `${position.left}px`;
1988
+ panel.style.top = `${position.top}px`;
1908
1989
  }
1909
1990
 
1910
1991
  function endSession() {
@@ -2293,15 +2374,6 @@ export function initComty(options = {}) {
2293
2374
  return element;
2294
2375
  }
2295
2376
 
2296
- async function waitForCommentTargetReady(comment) {
2297
- const el = resolveCommentElement(comment);
2298
- if (!el) return false;
2299
-
2300
- await waitForCustomElements(el);
2301
- await waitForPaint();
2302
- return true;
2303
- }
2304
-
2305
2377
  function getDeviceContext() {
2306
2378
  const width = window.innerWidth;
2307
2379
  const height = window.innerHeight;
@@ -2376,125 +2448,44 @@ export function initComty(options = {}) {
2376
2448
  if (!session.edit || hadDraft) hideInspector();
2377
2449
  document
2378
2450
  .querySelectorAll(
2379
- ".comty-bubble, .comty-create-bubble, .comty-temp-pin, .comty-popover",
2451
+ ".comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble, .comty-temp-pin, .comty-popover",
2380
2452
  )
2381
2453
  .forEach((x) => x.remove());
2382
2454
  }
2383
2455
 
2384
- const { waitForPaint, waitForCustomElements, captureViewportScreenshot } = createScreenshotCapture();
2385
-
2386
- async function buildCommentScreenshotFormData(payload) {
2387
- const formData = new FormData();
2388
-
2389
- Object.entries(payload).forEach(([key, value]) => {
2390
- if (value === undefined || value === null) return;
2391
- formData.append(key, String(value));
2392
- });
2456
+ const { captureViewportScreenshot } = createScreenshotCapture({ apiUrl, projectKey, getHeaders: getSdkHeaders });
2393
2457
 
2458
+ async function uploadCommentScreenshot(comment, context, point) {
2394
2459
  try {
2395
- await waitForCommentTargetReady(payload);
2396
- const screenshot = await captureViewportScreenshot();
2397
- if (screenshot) {
2398
- const extension =
2399
- screenshot.blob.type === "image/svg+xml"
2400
- ? "svg"
2401
- : screenshot.blob.type === "image/jpeg"
2402
- ? "jpg"
2403
- : screenshot.blob.type === "image/png"
2404
- ? "png"
2405
- : "webp";
2406
- formData.append(
2407
- "screenshot",
2408
- screenshot.blob,
2409
- `comment.${extension}`,
2410
- );
2411
- formData.append("screenshotWidth", String(screenshot.width));
2412
- formData.append("screenshotHeight", String(screenshot.height));
2413
- }
2414
- } catch (error) {
2415
- console.error("Comty: screenshot capture failed", error);
2416
- }
2417
-
2418
- return formData;
2419
- }
2420
-
2421
- async function uploadCommentScreenshot(
2422
- commentId,
2423
- payload,
2424
- captureNodes = [],
2425
- ) {
2426
- try {
2427
- const formData = await buildCommentScreenshotFormData({
2428
- projectKey,
2429
- host: window.location.host,
2430
- ...payload,
2431
- });
2432
-
2433
- captureNodes.forEach((node) => node.remove());
2434
-
2435
- if (!formData.has("screenshot")) return;
2436
-
2460
+ const screenshot = await captureViewportScreenshot({ context, annotation: () => mountCaptureAnnotation(comment, point) });
2461
+ if (!screenshot) return;
2462
+ const formData = new FormData();
2463
+ formData.append("projectKey", projectKey);
2464
+ formData.append("screenshot", screenshot.blob, screenshot.blob.type === "image/jpeg" ? "comment.jpg" : "comment.png");
2465
+ formData.append("screenshotWidth", String(screenshot.width));
2466
+ formData.append("screenshotHeight", String(screenshot.height));
2437
2467
  const response = await fetch(
2438
- `${apiUrl}/api/comments/${encodeURIComponent(commentId)}/screenshot`,
2439
- {
2440
- method: "POST",
2441
- headers: getSdkHeaders(),
2442
- body: formData,
2443
- },
2468
+ `${apiUrl}/api/comments/${encodeURIComponent(comment.id)}/screenshot`,
2469
+ { method: "POST", headers: getSdkHeaders(), body: formData },
2444
2470
  );
2445
-
2446
- if (!response.ok) {
2447
- console.error(
2448
- "Comty: screenshot upload failed",
2449
- await response.text(),
2450
- );
2451
- return;
2452
- }
2453
-
2454
- await loadComments({ force: true });
2471
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
2472
+ // Updating metadata must not redraw the page or interrupt a new draft.
2473
+ const { screenshotUrl } = await response.json();
2474
+ const cached = cachedComments.find(item => String(item.id) === String(comment.id));
2475
+ if (cached) Object.assign(cached, {
2476
+ screenshot_url: screenshotUrl,
2477
+ screenshot_width: screenshot.width,
2478
+ screenshot_height: screenshot.height,
2479
+ });
2455
2480
  } catch (error) {
2456
- console.error("Comty: screenshot upload failed", error);
2457
- captureNodes.forEach((node) => node.remove());
2458
- }
2459
- }
2460
-
2461
- function createCaptureAnnotation(pin, bubble, content) {
2462
- const capturePin = pin.cloneNode(false);
2463
- capturePin.dataset.comtyCaptureKeep = "true";
2464
- capturePin.classList.add("comty-capture-annotation");
2465
- capturePin.classList.remove("comty-temp-pin");
2466
- capturePin.style.display = "grid";
2467
- capturePin.style.pointerEvents = "none";
2468
- capturePin.style.animation = "none";
2469
- capturePin.style.opacity = "1";
2470
- capturePin.style.transform = "translate(-50%, -50%)";
2471
- setPinIcon(capturePin, currentUser || { name: "You" });
2472
-
2473
- const captureBubble = document.createElement("div");
2474
- captureBubble.className =
2475
- "comty-bubble comty-hover-bubble comty-capture-annotation";
2476
- captureBubble.dataset.comtyCaptureKeep = "true";
2477
- captureBubble.style.left = bubble.style.left;
2478
- captureBubble.style.top = bubble.style.top;
2479
- captureBubble.style.display = "block";
2480
- captureBubble.style.pointerEvents = "none";
2481
- captureBubble.style.animation = "none";
2482
- captureBubble.style.opacity = "1";
2483
- captureBubble.style.transform = "none";
2484
-
2485
- const contentEl = document.createElement("div");
2486
- contentEl.className = "comty-bubble-content";
2487
- contentEl.innerText = content;
2488
- captureBubble.appendChild(contentEl);
2489
-
2490
- document.body.appendChild(capturePin);
2491
- document.body.appendChild(captureBubble);
2492
- return [capturePin, captureBubble];
2481
+ console.warn("Comty: comment saved, screenshot unavailable", error);
2482
+ }
2493
2483
  }
2494
2484
 
2495
2485
  function addLocalComment(commentId, payload) {
2496
2486
  const comment = {
2497
2487
  id: commentId,
2488
+ pending: commentId.startsWith("pending-"),
2498
2489
  project_id: projectId,
2499
2490
  domain: payload.host || window.location.host,
2500
2491
  page_path: payload.pagePath || window.location.pathname,
@@ -2502,6 +2493,7 @@ export function initComty(options = {}) {
2502
2493
  rel_x: payload.relX,
2503
2494
  rel_y: payload.relY,
2504
2495
  content: payload.content || "",
2496
+ link_url: payload.linkUrl || null,
2505
2497
  created_at: new Date().toISOString(),
2506
2498
  dom_element: payload.domElement || null,
2507
2499
  viewport: payload.viewport || null,
@@ -2513,7 +2505,9 @@ export function initComty(options = {}) {
2513
2505
  screenshot_width: null,
2514
2506
  screenshot_height: null,
2515
2507
  anchor_context: payload.anchorContext || null,
2516
- author_user_id: currentUser?.id || null,
2508
+ author_user_id: currentUser?.isGuest ? null : currentUser?.id || null,
2509
+ guest_author_id: currentUser?.isGuest ? currentUser.id : null,
2510
+ guest_author_name: currentUser?.isGuest ? currentUser.name : null,
2517
2511
  author_name: currentUser?.name || null,
2518
2512
  author_avatar_url: currentUser?.avatarUrl || null,
2519
2513
  replies: [],
@@ -2528,6 +2522,7 @@ export function initComty(options = {}) {
2528
2522
  ];
2529
2523
  renderSidepanelComments();
2530
2524
  void renderComments(cachedComments, { retry: false });
2525
+ return comment;
2531
2526
  }
2532
2527
 
2533
2528
  function getCommentAuthorName(comment) {
@@ -2537,8 +2532,7 @@ export function initComty(options = {}) {
2537
2532
  function isOwnCommentEntry(entry) {
2538
2533
  return Boolean(
2539
2534
  currentUser?.id &&
2540
- entry?.author_user_id &&
2541
- entry.author_user_id === currentUser.id,
2535
+ (currentUser.isGuest ? entry?.guest_author_id : entry?.author_user_id) === currentUser.id,
2542
2536
  );
2543
2537
  }
2544
2538
 
@@ -2614,7 +2608,7 @@ export function initComty(options = {}) {
2614
2608
 
2615
2609
  function createThreadItem(
2616
2610
  entry,
2617
- { showMore = isOwnCommentEntry(entry) } = {},
2611
+ { showMore = isOwnCommentEntry(entry), showLink = true } = {},
2618
2612
  ) {
2619
2613
  const item = document.createElement("div");
2620
2614
  item.className = "comty-thread-item";
@@ -2649,15 +2643,51 @@ export function initComty(options = {}) {
2649
2643
 
2650
2644
  const content = document.createElement("div");
2651
2645
  content.className = "comty-bubble-content";
2652
- content.innerText = entry?.content || "";
2646
+ renderCommentContent(content, entry?.content || "");
2653
2647
 
2654
2648
  body.appendChild(author);
2655
2649
  body.appendChild(content);
2650
+ if (showLink && entry?.link_url) body.appendChild(createCommentLink(entry.link_url));
2656
2651
  item.appendChild(body);
2657
2652
 
2658
2653
  return { item, body, content, moreButton };
2659
2654
  }
2660
2655
 
2656
+ function renderCommentContent(element, value) {
2657
+ const content = String(value ?? "");
2658
+ const nodes = [];
2659
+ let previousEnd = 0;
2660
+ for (const match of content.matchAll(/https?:\/\/[^\s<>"']+/gi)) {
2661
+ const url = match[0].replace(/[.,!?;:]+$/, "");
2662
+ if (!url) continue;
2663
+ let parsed;
2664
+ try { parsed = new URL(url); } catch { continue; }
2665
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) continue;
2666
+ nodes.push(document.createTextNode(content.slice(previousEnd, match.index)));
2667
+ const link = document.createElement("a");
2668
+ link.href = url;
2669
+ link.target = "_blank";
2670
+ link.rel = "noopener noreferrer";
2671
+ link.textContent = url;
2672
+ nodes.push(link);
2673
+ previousEnd = match.index + url.length;
2674
+ }
2675
+ nodes.push(document.createTextNode(content.slice(previousEnd)));
2676
+ element.replaceChildren(...nodes);
2677
+ }
2678
+
2679
+ function createCommentLink(url) {
2680
+ const link = document.createElement("a");
2681
+ link.className = "comty-comment-link";
2682
+ link.href = url;
2683
+ link.target = "_blank";
2684
+ link.rel = "noopener noreferrer";
2685
+ link.title = url;
2686
+ link.setAttribute("aria-label", `Open attached link: ${url}`);
2687
+ link.textContent = url;
2688
+ return link;
2689
+ }
2690
+
2661
2691
  function setPinIcon(pin, comment = null) {
2662
2692
  if (comment) {
2663
2693
  pin.classList.add("comty-pin-with-avatar");
@@ -2674,6 +2704,54 @@ export function initComty(options = {}) {
2674
2704
  `;
2675
2705
  }
2676
2706
 
2707
+ function createCommentPin(comment, x, y) {
2708
+ const pin = document.createElement("button");
2709
+ pin.className = "comty-pin";
2710
+ pin.type = "button";
2711
+ pin.style.left = `${x}px`;
2712
+ pin.style.top = `${y}px`;
2713
+ setPinIcon(pin, comment);
2714
+ const statusDot = document.createElement("span");
2715
+ statusDot.className = "comty-pin-status-dot";
2716
+ applyStatusDot(statusDot, comment.status);
2717
+ pin.appendChild(statusDot);
2718
+ return pin;
2719
+ }
2720
+
2721
+ function createCommentPreview(comment) {
2722
+ const bubble = document.createElement("div");
2723
+ bubble.className = "comty-bubble comty-hover-bubble";
2724
+ const thread = document.createElement("div");
2725
+ thread.className = "comty-thread";
2726
+ thread.appendChild(createThreadItem(comment, { showMore: false, showLink: false }).item);
2727
+ bubble.appendChild(thread);
2728
+ return bubble;
2729
+ }
2730
+
2731
+ function mountCaptureAnnotation(comment, { x, y }) {
2732
+ const host = document.createElement("div");
2733
+ host.dataset.comtyCaptureExclude = "";
2734
+ host.setAttribute("aria-hidden", "true");
2735
+ host.inert = true;
2736
+ // Ancestor opacity hides measurement without changing the components'
2737
+ // computed opacity. Nothing flashes or intercepts input during cloning.
2738
+ Object.assign(host.style, { position: "fixed", inset: "0", opacity: "0", pointerEvents: "none" });
2739
+ const pin = createCommentPin(comment, x, y);
2740
+ const bubble = createCommentPreview(comment);
2741
+ for (const element of [pin, bubble]) {
2742
+ element.dataset.comtyCaptureKeep = "";
2743
+ element.style.setProperty("animation", "none", "important");
2744
+ element.style.setProperty("transition", "none", "important");
2745
+ element.style.setProperty("pointer-events", "none", "important");
2746
+ }
2747
+ pin.style.display = "grid";
2748
+ bubble.style.display = "block";
2749
+ host.append(pin, bubble);
2750
+ root.appendChild(host);
2751
+ positionCommentBubble(bubble, x, y);
2752
+ return { elements: [pin, bubble], dispose: () => host.remove() };
2753
+ }
2754
+
2677
2755
  function createCommentAuthor(comment) {
2678
2756
  const author = document.createElement("div");
2679
2757
  author.className = "comty-comment-author";
@@ -2724,25 +2802,86 @@ export function initComty(options = {}) {
2724
2802
  const actions = document.createElement("div");
2725
2803
  actions.className = "comty-create-actions";
2726
2804
 
2805
+ const linkBtn = document.createElement("button");
2806
+ linkBtn.type = "button";
2807
+ linkBtn.className = "comty-create-link-button";
2808
+ linkBtn.setAttribute("aria-label", "Add link");
2809
+ linkBtn.innerHTML = `
2810
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2811
+ <path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
2812
+ <path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
2813
+ </svg>
2814
+ `;
2815
+
2816
+ const linkEditor = document.createElement("div");
2817
+ linkEditor.className = "comty-create-link-editor";
2818
+ linkEditor.inert = true;
2819
+ linkEditor.setAttribute("aria-hidden", "true");
2820
+ const linkInput = document.createElement("input");
2821
+ linkInput.type = "url";
2822
+ linkInput.placeholder = "Paste a link";
2823
+ linkInput.setAttribute("aria-label", "Link URL");
2824
+ const linkInsert = document.createElement("button");
2825
+ linkInsert.type = "button";
2826
+ linkInsert.textContent = "Add";
2827
+ const linkCancel = document.createElement("button");
2828
+ linkCancel.type = "button";
2829
+ linkCancel.setAttribute("aria-label", "Cancel link");
2830
+ linkCancel.textContent = "×";
2831
+ const linkField = document.createElement("div");
2832
+ linkField.className = "comty-create-link-field";
2833
+ linkField.append(linkInput, linkCancel);
2834
+ linkEditor.append(linkField, linkInsert);
2835
+
2836
+ const attachment = document.createElement("div");
2837
+ attachment.className = "comty-create-attachment";
2838
+ const attachmentLink = document.createElement("a");
2839
+ attachmentLink.className = "comty-comment-link";
2840
+ attachmentLink.target = "_blank";
2841
+ attachmentLink.rel = "noopener noreferrer";
2842
+ const removeAttachment = document.createElement("button");
2843
+ removeAttachment.type = "button";
2844
+ removeAttachment.setAttribute("aria-label", "Remove attached link");
2845
+ removeAttachment.textContent = "×";
2846
+ const attachmentChip = document.createElement("div");
2847
+ attachmentChip.className = "comty-create-attachment-chip";
2848
+ attachmentChip.append(attachmentLink, removeAttachment);
2849
+ attachment.appendChild(attachmentChip);
2850
+ let attachedLink = "";
2851
+
2727
2852
  const submitBtn = document.createElement("button");
2853
+ submitBtn.type = "button";
2728
2854
  submitBtn.className = "comty-btn-submit";
2729
2855
  submitBtn.setAttribute("aria-label", "Post comment");
2856
+ submitBtn.disabled = true;
2730
2857
  submitBtn.innerHTML = `
2731
2858
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2732
2859
  <path d="M12 19V5M5 12l7-7 7 7"/>
2733
2860
  </svg>
2734
2861
  `;
2735
2862
 
2863
+ const editorDraft = draftAnchor;
2736
2864
  const submit = async () => {
2737
2865
  const content = textarea.value.trim();
2738
- if (!content) return;
2866
+ if (!content || textarea.disabled) return;
2739
2867
 
2740
2868
  submitBtn.disabled = true;
2741
2869
  textarea.disabled = true;
2870
+ linkBtn.disabled = true;
2742
2871
 
2743
2872
  payload.content = content;
2873
+ payload.linkUrl = attachedLink || null;
2744
2874
  textarea.textContent = content;
2745
2875
 
2876
+ const context = captureContext();
2877
+ const point = getPinPoint(pin);
2878
+ const pendingId = `pending-${crypto.randomUUID()}`;
2879
+ // Paint the comment immediately. The network save never waits for capture work.
2880
+ bubble.remove();
2881
+ pin.remove();
2882
+ if (draftAnchor === editorDraft) draftAnchor = null;
2883
+ hideInspector();
2884
+ addLocalComment(pendingId, payload);
2746
2885
  let post;
2747
2886
  try {
2748
2887
  post = await fetch(`${apiUrl}/api/comments`, {
@@ -2750,35 +2889,32 @@ export function initComty(options = {}) {
2750
2889
  headers: getSdkHeaders({ "Content-Type": "application/json" }),
2751
2890
  body: JSON.stringify(payload),
2752
2891
  });
2892
+ if (!post.ok) throw new Error(`Comment save failed (${post.status})`);
2893
+ const data = await post.json();
2894
+ if (!data?.id) throw new Error("Comment save returned no ID");
2895
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2896
+ if (context.url === location.href) {
2897
+ const comment = addLocalComment(String(data.id), payload);
2898
+ // Start only after the save and immediate UI update; the capture yields to input.
2899
+ void uploadCommentScreenshot(comment, context, point);
2900
+ } else void loadComments({ force: true });
2753
2901
  } catch (error) {
2754
2902
  console.error("Comty: save failed", error);
2903
+ cachedComments = cachedComments.filter(item => String(item.id) !== pendingId);
2904
+ renderSidepanelComments();
2905
+ void renderComments(cachedComments, { retry: false });
2906
+ // Keep the original editor and its text available for retry, including attachments.
2907
+ if (!draftAnchor) draftAnchor = editorDraft;
2908
+ root.append(pin, bubble);
2755
2909
  submitBtn.disabled = false;
2756
2910
  textarea.disabled = false;
2757
- return;
2758
- }
2759
-
2760
- if (!post.ok) {
2761
- console.error("Comty: save failed", await post.text());
2762
- submitBtn.disabled = false;
2763
- textarea.disabled = false;
2764
- return;
2765
- }
2766
-
2767
- const data = await post.json();
2768
- const commentId = data?.id;
2769
- const captureNodes = commentId
2770
- ? createCaptureAnnotation(pin, bubble, content)
2771
- : [];
2772
- bubble.remove();
2773
- pin.remove();
2774
- draftAnchor = null;
2775
- hideInspector();
2776
- if (commentId) {
2777
- addLocalComment(String(commentId), payload);
2778
- void loadComments({ force: true });
2779
- void uploadCommentScreenshot(String(commentId), payload, captureNodes);
2780
- } else {
2781
- void loadComments({ force: true });
2911
+ linkBtn.disabled = false;
2912
+ const errorMessage = document.createElement("p");
2913
+ errorMessage.setAttribute("role", "alert");
2914
+ errorMessage.textContent = "Comment not saved. Your draft is ready to retry.";
2915
+ bubble.querySelector('[role="alert"]')?.remove();
2916
+ bubble.append(errorMessage);
2917
+ if (bubble.isConnected) textarea.focus();
2782
2918
  }
2783
2919
  };
2784
2920
 
@@ -2787,8 +2923,63 @@ export function initComty(options = {}) {
2787
2923
  submit();
2788
2924
  };
2789
2925
 
2926
+ const closeLinkEditor = () => {
2927
+ bubble.classList.remove("comty-create-link-open");
2928
+ linkEditor.inert = true;
2929
+ linkEditor.setAttribute("aria-hidden", "true");
2930
+ linkInput.value = "";
2931
+ linkInput.removeAttribute("aria-invalid");
2932
+ linkInput.setCustomValidity("");
2933
+ textarea.focus();
2934
+ };
2935
+ linkBtn.onclick = () => {
2936
+ if (!bubble.classList.contains("comty-create-expanded")) return;
2937
+ bubble.classList.add("comty-create-link-open");
2938
+ linkEditor.inert = false;
2939
+ linkEditor.setAttribute("aria-hidden", "false");
2940
+ linkInput.focus();
2941
+ };
2942
+ linkCancel.onclick = closeLinkEditor;
2943
+ linkInput.oninput = () => {
2944
+ linkInput.removeAttribute("aria-invalid");
2945
+ linkInput.setCustomValidity("");
2946
+ };
2947
+ linkInput.onkeydown = (event) => {
2948
+ if (event.key === "Enter") { event.preventDefault(); linkInsert.click(); }
2949
+ if (event.key === "Escape") { event.preventDefault(); closeLinkEditor(); }
2950
+ };
2951
+ linkInsert.onclick = () => {
2952
+ const value = linkInput.value.trim();
2953
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
2954
+ let url;
2955
+ try { url = new URL(candidate); } catch { /* Report below. */ }
2956
+ if (!url || !["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/.test(value) || String(url).length > 2048) {
2957
+ linkInput.setAttribute("aria-invalid", "true");
2958
+ linkInput.setCustomValidity("Enter a valid HTTP(S) URL.");
2959
+ linkInput.reportValidity();
2960
+ linkInput.focus();
2961
+ return;
2962
+ }
2963
+ attachedLink = String(url);
2964
+ attachmentLink.href = attachedLink;
2965
+ attachmentLink.title = attachedLink;
2966
+ attachmentLink.textContent = attachedLink;
2967
+ bubble.classList.add("comty-create-has-attachment");
2968
+ closeLinkEditor();
2969
+ const point = getPinPoint(pin);
2970
+ positionCommentBubble(bubble, point.x, point.y);
2971
+ renderInspector();
2972
+ };
2973
+
2974
+ removeAttachment.onclick = () => {
2975
+ attachedLink = "";
2976
+ bubble.classList.remove("comty-create-has-attachment");
2977
+ textarea.focus();
2978
+ textarea.oninput();
2979
+ };
2980
+
2790
2981
  textarea.onkeydown = (e) => {
2791
- if (e.key === "Enter" && !e.shiftKey) {
2982
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
2792
2983
  e.preventDefault();
2793
2984
  submit();
2794
2985
  }
@@ -2799,31 +2990,20 @@ export function initComty(options = {}) {
2799
2990
  };
2800
2991
 
2801
2992
  textarea.oninput = () => {
2802
- textarea.style.height = "24px";
2803
- const scrollHeight = textarea.scrollHeight;
2804
- textarea.style.height = scrollHeight + "px";
2805
-
2806
- if (scrollHeight > 24) {
2807
- bubble.style.borderRadius = "16px";
2808
- bubble.style.alignItems = "flex-end";
2809
- } else {
2810
- bubble.style.borderRadius = "999px";
2811
- bubble.style.alignItems = "center";
2812
- }
2813
-
2814
- if (textarea.value.trim().length > 0) {
2815
- submitBtn.classList.add("comty-btn-active");
2816
- } else {
2817
- submitBtn.classList.remove("comty-btn-active");
2818
- }
2993
+ const expanded = textarea.value.length > 0 || Boolean(attachedLink);
2994
+ bubble.classList.toggle("comty-create-expanded", expanded);
2995
+ if (!expanded && bubble.classList.contains("comty-create-link-open")) closeLinkEditor();
2996
+ submitBtn.disabled = !textarea.value.trim();
2997
+ submitBtn.classList.toggle("comty-btn-active", !submitBtn.disabled);
2819
2998
  const point = getPinPoint(pin);
2820
2999
  positionCommentBubble(bubble, point.x, point.y);
2821
3000
  renderInspector();
2822
3001
  };
2823
3002
 
2824
- actions.appendChild(submitBtn);
3003
+ actions.append(linkBtn, submitBtn, linkEditor);
2825
3004
 
2826
3005
  bubble.appendChild(textarea);
3006
+ bubble.appendChild(attachment);
2827
3007
  bubble.appendChild(actions);
2828
3008
 
2829
3009
  root.appendChild(bubble);
@@ -2932,6 +3112,7 @@ export function initComty(options = {}) {
2932
3112
  const item = document.createElement("button");
2933
3113
  item.type = "button";
2934
3114
  item.className = "comty-sidepanel-item";
3115
+ item.disabled = Boolean(comment.pending);
2935
3116
  item.innerHTML = `
2936
3117
  <span class="comty-sidepanel-item-status" aria-hidden="true"></span>
2937
3118
  <span class="comty-sidepanel-item-body">
@@ -2952,7 +3133,7 @@ export function initComty(options = {}) {
2952
3133
 
2953
3134
  const metaEl = item.querySelector(".comty-sidepanel-item-meta");
2954
3135
  if (metaEl instanceof HTMLElement) {
2955
- metaEl.innerText = `${comment.author_name || "Anonymous"} · ${getCommentLabel(comment)}`;
3136
+ metaEl.innerText = `${comment.author_name || "Anonymous"} · ${comment.pending ? "Sending…" : getCommentLabel(comment)}`;
2956
3137
  metaEl.title = getCommentLabel(comment);
2957
3138
  }
2958
3139
 
@@ -3283,11 +3464,13 @@ export function initComty(options = {}) {
3283
3464
  const openCommentThread = createCommentThread({
3284
3465
  root, apiUrl, projectKey, commentStatuses, selectedStatusFilters, placedPins,
3285
3466
  closeBubbles, getCommentStatus, updateCommentStatus, applyStatusDot,
3286
- renderSidepanelComments, applyStatusFilters, createThreadItem,
3467
+ renderSidepanelComments, applyStatusFilters, createThreadItem, renderCommentContent,
3287
3468
  isOwnCommentEntry, updateCommentContent, deleteSdkComment, getCommentId,
3288
3469
  updateReplyContent, deleteSdkReply, createCommentAvatar, getSdkHeaders,
3289
3470
  showAuthPrompt,
3290
3471
  get canWrite() { return canWrite; },
3472
+ get canManage() { return canManage; },
3473
+ get allowGuestComments() { return allowGuestComments; },
3291
3474
  get currentUser() { return currentUser; },
3292
3475
  get cachedComments() { return cachedComments; },
3293
3476
  set cachedComments(value) { cachedComments = value; },
@@ -3337,20 +3520,11 @@ export function initComty(options = {}) {
3337
3520
  const x = visibility.clientX;
3338
3521
  const y = visibility.clientY;
3339
3522
 
3340
- const pin = document.createElement("button");
3341
- pin.className = "comty-pin";
3342
- pin.type = "button";
3523
+ const pin = createCommentPin(comment, x, y);
3524
+ pin.disabled = Boolean(comment.pending);
3343
3525
  pin.dataset.status = getCommentStatus(comment.status).value;
3344
- if (comment.id) {
3345
- pin.dataset.commentId = String(comment.id);
3346
- }
3347
- pin.style.left = `${x}px`;
3348
- pin.style.top = `${y}px`;
3349
- setPinIcon(pin, comment);
3350
- const pinStatusDot = document.createElement("span");
3351
- pinStatusDot.className = "comty-pin-status-dot";
3352
- applyStatusDot(pinStatusDot, comment.status);
3353
- pin.appendChild(pinStatusDot);
3526
+ if (comment.id) pin.dataset.commentId = String(comment.id);
3527
+ const pinStatusDot = pin.querySelector(".comty-pin-status-dot");
3354
3528
  pin.setAttribute(
3355
3529
  "aria-label",
3356
3530
  `Afficher le commentaire, statut ${getCommentStatus(comment.status).label}`,
@@ -3392,32 +3566,14 @@ export function initComty(options = {}) {
3392
3566
  activeHoverCommentIds.add(commentId);
3393
3567
  }
3394
3568
 
3395
- const hoverBubble = document.createElement("div");
3396
- hoverBubble.className = "comty-bubble comty-hover-bubble";
3569
+ const hoverBubble = createCommentPreview(comment);
3397
3570
  hoverBubble.style.left = `${x + 32}px`;
3398
3571
  hoverBubble.style.top = `${y - 17}px`;
3399
- hoverBubble.style.pointerEvents = "none";
3400
3572
  hoverBubble.dataset.hoverId = commentId;
3401
3573
 
3402
- const hoverThread = document.createElement("div");
3403
- hoverThread.className = "comty-thread";
3404
- const hoverItem = createThreadItem(comment, { showMore: false });
3405
- hoverThread.appendChild(hoverItem.item);
3406
- hoverBubble.appendChild(hoverThread);
3407
-
3408
3574
  root.appendChild(hoverBubble);
3409
3575
 
3410
- requestAnimationFrame(() => {
3411
- if (x + 16 + hoverBubble.offsetWidth > window.innerWidth) {
3412
- hoverBubble.style.left = `${x - hoverBubble.offsetWidth - 24}px`;
3413
- hoverBubble.classList.add("comty-bubble-left");
3414
- }
3415
-
3416
- const rect = hoverBubble.getBoundingClientRect();
3417
- if (rect.bottom > window.innerHeight) {
3418
- hoverBubble.style.top = `${y - rect.height - 16}px`;
3419
- }
3420
- });
3576
+ requestAnimationFrame(() => positionCommentBubble(hoverBubble, x, y));
3421
3577
  };
3422
3578
 
3423
3579
  pin.onmouseleave = () => {
@@ -3514,6 +3670,9 @@ export function initComty(options = {}) {
3514
3670
  cachedComments = comments
3515
3671
  .filter(comment => comment.kind !== "agent" && !comment.local_agent)
3516
3672
  .map(comment => Object.assign(previous.get(String(comment.id)) || {}, comment));
3673
+ for (const comment of previous.values()) {
3674
+ if (comment.pending && !cachedComments.some(item => item.id === comment.id)) cachedComments.push(comment);
3675
+ }
3517
3676
  }
3518
3677
 
3519
3678
  async function loadComments({ force = false } = {}) {
@@ -3734,7 +3893,6 @@ export function initComty(options = {}) {
3734
3893
  hideInspector();
3735
3894
  return;
3736
3895
  }
3737
- if (inspectedElement !== target) inspectExpanded = selectedElement ? inspectExpanded : false;
3738
3896
  showInspector(target);
3739
3897
  }, true);
3740
3898
  document.documentElement.addEventListener("pointerleave", () => {
@@ -3791,7 +3949,6 @@ export function initComty(options = {}) {
3791
3949
  }
3792
3950
  measuredRegion = null;
3793
3951
  selectedElement = getAnchorElement(target);
3794
- inspectExpanded = false;
3795
3952
  showInspector(target);
3796
3953
  return;
3797
3954
  }
@@ -3949,13 +4106,26 @@ export function initComty(options = {}) {
3949
4106
  const data = await res.json();
3950
4107
  projectId = data.projectId;
3951
4108
  canWrite = Boolean(data.canWrite);
4109
+ canManage = Boolean(data.canManage ?? data.canWrite);
4110
+ allowGuestComments = Boolean(data.allowGuestComments);
3952
4111
  currentUser = data.user || null;
3953
4112
  loginUrl = data.loginUrl || "";
3954
4113
 
3955
4114
  if (!canWrite && sdkToken) {
3956
- sessionStorage.removeItem(tokenStorageKey);
4115
+ try { sessionStorage.removeItem(tokenStorageKey); } catch { /* Storage may be unavailable. */ }
3957
4116
  sdkToken = "";
3958
4117
  }
4118
+ if (didCompleteSignIn || authStatus === "forbidden") {
4119
+ let previousSession;
4120
+ try {
4121
+ previousSession = JSON.parse(sessionStorage.getItem(authReturnStorageKey) || "null");
4122
+ sessionStorage.removeItem(authReturnStorageKey);
4123
+ } catch { /* Fall back to the requested comment mode. */ }
4124
+ const mode = canWrite && ["browse", "edit", "inspect"].includes(previousSession?.mode)
4125
+ ? previousSession.mode : canWrite ? "edit" : "browse";
4126
+ setSession(mode, { entry: previousSession?.entry || mode });
4127
+ if (previousSession?.sidepanelOpen) setSidepanelOpen(true);
4128
+ }
3959
4129
  if (authStatus === "forbidden") showAuthPrompt(true);
3960
4130
  if (canWrite && didCompleteSignIn) {
3961
4131
  const toast = document.createElement("div");
@@ -3984,7 +4154,7 @@ export function initComty(options = {}) {
3984
4154
  setSession(session.comments ? session.mode : "browse", { entry: sessionEntry, renderComments: true });
3985
4155
  return renderComments(cachedComments, { retry: false });
3986
4156
  },
3987
- setCanWrite(value) { canWrite = value; },
4157
+ setCanWrite(value) { canWrite = value; canManage = value; },
3988
4158
  invalidate() { anchorRevision += 1; },
3989
4159
  });
3990
4160
  }