@comty.io/core 0.1.3 → 0.1.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.
- package/README.md +18 -0
- package/comty-sdk.js +1231 -940
- package/package.json +8 -1
- package/src/browser.js +432 -262
- package/src/capture-engine.js +26 -0
- package/src/capture-resources.js +94 -0
- package/src/capture.js +249 -459
- package/src/comments.js +41 -24
- package/src/style.js +360 -165
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
1419
|
-
<
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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("
|
|
1445
|
-
|
|
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",
|
|
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,
|
|
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
|
|
1861
|
-
const
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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.
|
|
1877
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
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 {
|
|
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
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
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(
|
|
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
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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.
|
|
2457
|
-
|
|
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
|
|
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
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
textarea.
|
|
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.
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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.
|
|
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 =
|
|
3341
|
-
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
|
-
|
|
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 =
|
|
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
|
}
|