@nuniapp/widget 0.1.4 → 0.1.6
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 +7 -1
- package/dist/index.d.ts +143 -1
- package/dist/index.js +2272 -324
- package/dist/{mark-BeysEAHp.js → mark-B8wojiFu.js} +25 -1
- package/dist/nuni-screenshot.global.js +2 -2
- package/dist/nuni.global.js +177 -8
- package/dist/{screenshot-B2x2-nI_.js → screenshot-DcJ_SzKF.js} +4 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as REACTIONS, i as LIMITS, n as MASK_ATTRIBUTE, o as generateSecret, r as MASK_SELECTOR, s as isProjectId, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
|
|
2
2
|
//#region ../shared/src/path.ts
|
|
3
3
|
/**
|
|
4
4
|
* Normalize a URL to the key comments are matched by. Query and hash are
|
|
@@ -91,6 +91,13 @@ function stripHtmlUrlQueries(html) {
|
|
|
91
91
|
}));
|
|
92
92
|
}
|
|
93
93
|
//#endregion
|
|
94
|
+
//#region ../shared/src/prompt.ts
|
|
95
|
+
const PACKAGES = {
|
|
96
|
+
widget: "@nuniapp/widget",
|
|
97
|
+
react: "@nuniapp/react",
|
|
98
|
+
cli: "@nuniapp/cli"
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
94
101
|
//#region ../shared/src/comment-prompt.ts
|
|
95
102
|
/** A code fence longer than any backtick run inside `content`. */
|
|
96
103
|
function fence(content, lang = "") {
|
|
@@ -114,6 +121,11 @@ function buildCommentPrompt(comment, options = {}) {
|
|
|
114
121
|
const { anchor, page } = comment;
|
|
115
122
|
const out = [];
|
|
116
123
|
out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
|
|
124
|
+
if (comment.replies?.length) {
|
|
125
|
+
out.push("", `## Replies (${comment.replies.length})`, "");
|
|
126
|
+
for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
|
|
127
|
+
out.pop();
|
|
128
|
+
}
|
|
117
129
|
out.push("", "## Where", "");
|
|
118
130
|
out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
|
|
119
131
|
out.push(`- Open it: ${commentUrl(page, comment._id)}`);
|
|
@@ -122,6 +134,12 @@ function buildCommentPrompt(comment, options = {}) {
|
|
|
122
134
|
out.push("", "## Element", "");
|
|
123
135
|
out.push(`- Tag: <${anchor.tag}>`);
|
|
124
136
|
if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
|
|
137
|
+
if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
|
|
138
|
+
if (anchor.region) {
|
|
139
|
+
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
140
|
+
const r = anchor.region;
|
|
141
|
+
out.push(`- Area: ${pct(r.w)} × ${pct(r.h)} of the element, from ${pct(r.x)} left and ${pct(r.y)} top`);
|
|
142
|
+
}
|
|
125
143
|
if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
|
|
126
144
|
if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
|
|
127
145
|
if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
|
|
@@ -142,19 +160,27 @@ function buildCommentPrompt(comment, options = {}) {
|
|
|
142
160
|
for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
|
|
143
161
|
}
|
|
144
162
|
if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
|
|
145
|
-
out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.");
|
|
163
|
+
out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
|
|
146
164
|
return out.join("\n");
|
|
147
165
|
}
|
|
148
166
|
//#endregion
|
|
149
167
|
//#region src/config.ts
|
|
150
|
-
const VERSION = "0.1.
|
|
168
|
+
const VERSION = "0.1.6";
|
|
151
169
|
function resolveConfig(options) {
|
|
152
170
|
return {
|
|
153
171
|
project: options.project,
|
|
154
172
|
convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
|
|
155
173
|
convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
|
|
156
174
|
appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
|
|
157
|
-
getPageKey: options.getPageKey,
|
|
175
|
+
getPageKey: options.getPageKey ?? pageKeyFor(options.pageKey),
|
|
176
|
+
position: oneOf(options.position, POSITIONS, "bottom-right"),
|
|
177
|
+
accentColor: options.accentColor?.trim() || null,
|
|
178
|
+
theme: oneOf(options.theme, THEMES, "auto"),
|
|
179
|
+
label: options.label?.trim() || null,
|
|
180
|
+
hotkey: options.hotkey === false ? null : options.hotkey?.trim().slice(0, 1).toLowerCase() || "c",
|
|
181
|
+
zIndex: typeof options.zIndex === "number" && Number.isFinite(options.zIndex) ? Math.round(options.zIndex) : null,
|
|
182
|
+
locale: options.locale?.trim() || void 0,
|
|
183
|
+
messages: options.messages ?? {},
|
|
158
184
|
capture: {
|
|
159
185
|
console: options.capture?.console ?? true,
|
|
160
186
|
network: options.capture?.network ?? true,
|
|
@@ -163,6 +189,35 @@ function resolveConfig(options) {
|
|
|
163
189
|
}
|
|
164
190
|
};
|
|
165
191
|
}
|
|
192
|
+
const POSITIONS = [
|
|
193
|
+
"bottom-right",
|
|
194
|
+
"bottom-left",
|
|
195
|
+
"top-right",
|
|
196
|
+
"top-left"
|
|
197
|
+
];
|
|
198
|
+
const THEMES = [
|
|
199
|
+
"auto",
|
|
200
|
+
"light",
|
|
201
|
+
"dark"
|
|
202
|
+
];
|
|
203
|
+
function oneOf(value, allowed, fallback) {
|
|
204
|
+
return allowed.includes(value) ? value : fallback;
|
|
205
|
+
}
|
|
206
|
+
/** Query parameters that never tell pages apart. */
|
|
207
|
+
const IGNORED_PARAMS = /^(nuni|utm_.*|fbclid|gclid|msclkid|ref)$/i;
|
|
208
|
+
function pageKeyFor(preset = "path") {
|
|
209
|
+
return (url) => {
|
|
210
|
+
const path = normalizePath(url);
|
|
211
|
+
if (preset === "path+search") {
|
|
212
|
+
const params = [...url.searchParams].filter(([name]) => !IGNORED_PARAMS.test(name)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
213
|
+
return params.length ? `${path}?${new URLSearchParams(params).toString()}` : path;
|
|
214
|
+
}
|
|
215
|
+
if (preset === "path+hash" && url.hash.length > 1 && !path.includes("#")) {
|
|
216
|
+
if (!/^#!?\//.test(url.hash)) return `${path}${url.hash}`;
|
|
217
|
+
}
|
|
218
|
+
return path;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
166
221
|
//#endregion
|
|
167
222
|
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
|
|
168
223
|
function _typeof(o) {
|
|
@@ -513,6 +568,463 @@ function attributeAllowed(name, options) {
|
|
|
513
568
|
return !prefixes || !prefixes.some((p) => name.startsWith(p));
|
|
514
569
|
}
|
|
515
570
|
//#endregion
|
|
571
|
+
//#region ../anchor/src/text.ts
|
|
572
|
+
const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
573
|
+
"SCRIPT",
|
|
574
|
+
"STYLE",
|
|
575
|
+
"NOSCRIPT",
|
|
576
|
+
"TEMPLATE",
|
|
577
|
+
"SVG"
|
|
578
|
+
]);
|
|
579
|
+
function normalizeText(value) {
|
|
580
|
+
return value.replace(/\s+/g, " ").trim();
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Visible-ish text of an element, whitespace-normalized and truncated.
|
|
584
|
+
* Areas marked data-nuni-mask count as empty, both when capturing and when
|
|
585
|
+
* resolving, so their (private) text is never stored and matching stays
|
|
586
|
+
* consistent.
|
|
587
|
+
*/
|
|
588
|
+
function elementText(el, max = 120) {
|
|
589
|
+
if (el.closest(MASK_SELECTOR)) return "";
|
|
590
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
|
|
591
|
+
let out = "";
|
|
592
|
+
const walk = (node) => {
|
|
593
|
+
if (out.length > max * 2) return;
|
|
594
|
+
if (node.nodeType === 3) {
|
|
595
|
+
out += node.nodeValue ?? "";
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (node.nodeType !== 1) return;
|
|
599
|
+
const tag = node.tagName.toUpperCase();
|
|
600
|
+
if (SKIP_TEXT_TAGS.has(tag)) return;
|
|
601
|
+
if (node.hasAttribute("data-nuni-mask")) return;
|
|
602
|
+
if (node.hasAttribute("data-nuni")) return;
|
|
603
|
+
if (tag === "IMG") {
|
|
604
|
+
out += " " + (node.getAttribute("alt") ?? "") + " ";
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const shadow = node.shadowRoot;
|
|
608
|
+
if (shadow) {
|
|
609
|
+
for (const child of Array.from(shadow.childNodes)) walk(child);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (tag === "SLOT") {
|
|
613
|
+
const assigned = node.assignedNodes?.({ flatten: true });
|
|
614
|
+
if (assigned?.length) {
|
|
615
|
+
for (const child of assigned) walk(child);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const child of Array.from(node.childNodes)) walk(child);
|
|
620
|
+
if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
|
|
621
|
+
};
|
|
622
|
+
walk(el);
|
|
623
|
+
return normalizeText(out).slice(0, max);
|
|
624
|
+
}
|
|
625
|
+
function bigrams(value) {
|
|
626
|
+
const s = value.toLowerCase();
|
|
627
|
+
const map = /* @__PURE__ */ new Map();
|
|
628
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
629
|
+
const gram = s.slice(i, i + 2);
|
|
630
|
+
map.set(gram, (map.get(gram) ?? 0) + 1);
|
|
631
|
+
}
|
|
632
|
+
return map;
|
|
633
|
+
}
|
|
634
|
+
/** Sørensen-Dice coefficient on character bigrams, 0..1. */
|
|
635
|
+
function textSimilarity(a, b) {
|
|
636
|
+
if (a === b) return 1;
|
|
637
|
+
const na = a.toLowerCase();
|
|
638
|
+
const nb = b.toLowerCase();
|
|
639
|
+
if (na === nb) return 1;
|
|
640
|
+
if (na.length < 2 || nb.length < 2) return 0;
|
|
641
|
+
const ga = bigrams(na);
|
|
642
|
+
const gb = bigrams(nb);
|
|
643
|
+
let overlap = 0;
|
|
644
|
+
for (const [gram, count] of ga) {
|
|
645
|
+
const other = gb.get(gram);
|
|
646
|
+
if (other) overlap += Math.min(count, other);
|
|
647
|
+
}
|
|
648
|
+
return 2 * overlap / (na.length - 1 + (nb.length - 1));
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region ../anchor/src/quote.ts
|
|
652
|
+
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
653
|
+
"SCRIPT",
|
|
654
|
+
"STYLE",
|
|
655
|
+
"NOSCRIPT",
|
|
656
|
+
"TEMPLATE",
|
|
657
|
+
"SVG"
|
|
658
|
+
]);
|
|
659
|
+
/** Elements whose edges read as a space between words. */
|
|
660
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
661
|
+
"ADDRESS",
|
|
662
|
+
"ARTICLE",
|
|
663
|
+
"ASIDE",
|
|
664
|
+
"BLOCKQUOTE",
|
|
665
|
+
"BR",
|
|
666
|
+
"DD",
|
|
667
|
+
"DIV",
|
|
668
|
+
"DL",
|
|
669
|
+
"DT",
|
|
670
|
+
"FIGCAPTION",
|
|
671
|
+
"FIGURE",
|
|
672
|
+
"FOOTER",
|
|
673
|
+
"FORM",
|
|
674
|
+
"H1",
|
|
675
|
+
"H2",
|
|
676
|
+
"H3",
|
|
677
|
+
"H4",
|
|
678
|
+
"H5",
|
|
679
|
+
"H6",
|
|
680
|
+
"HEADER",
|
|
681
|
+
"HR",
|
|
682
|
+
"LI",
|
|
683
|
+
"MAIN",
|
|
684
|
+
"NAV",
|
|
685
|
+
"OL",
|
|
686
|
+
"P",
|
|
687
|
+
"PRE",
|
|
688
|
+
"SECTION",
|
|
689
|
+
"TABLE",
|
|
690
|
+
"TD",
|
|
691
|
+
"TH",
|
|
692
|
+
"TR",
|
|
693
|
+
"UL"
|
|
694
|
+
]);
|
|
695
|
+
/** Formatting inside a paragraph: a quote climbs out of these. */
|
|
696
|
+
const INLINE_TAGS = /* @__PURE__ */ new Set([
|
|
697
|
+
"A",
|
|
698
|
+
"ABBR",
|
|
699
|
+
"B",
|
|
700
|
+
"BDI",
|
|
701
|
+
"BDO",
|
|
702
|
+
"CITE",
|
|
703
|
+
"CODE",
|
|
704
|
+
"DATA",
|
|
705
|
+
"DEL",
|
|
706
|
+
"DFN",
|
|
707
|
+
"EM",
|
|
708
|
+
"I",
|
|
709
|
+
"INS",
|
|
710
|
+
"KBD",
|
|
711
|
+
"LABEL",
|
|
712
|
+
"MARK",
|
|
713
|
+
"Q",
|
|
714
|
+
"S",
|
|
715
|
+
"SAMP",
|
|
716
|
+
"SMALL",
|
|
717
|
+
"SPAN",
|
|
718
|
+
"STRONG",
|
|
719
|
+
"SUB",
|
|
720
|
+
"SUP",
|
|
721
|
+
"TIME",
|
|
722
|
+
"U",
|
|
723
|
+
"VAR"
|
|
724
|
+
]);
|
|
725
|
+
/** Longest text searched for a quote inside one element. */
|
|
726
|
+
const MAX_TEXT = 2e4;
|
|
727
|
+
/** Longest text searched for an edited quote, word by word. */
|
|
728
|
+
const MAX_FUZZY_TEXT = 6e3;
|
|
729
|
+
const FUZZY_MIN = .75;
|
|
730
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
731
|
+
function textMap(el) {
|
|
732
|
+
const map = {
|
|
733
|
+
text: "",
|
|
734
|
+
nodes: [],
|
|
735
|
+
node: [],
|
|
736
|
+
offset: []
|
|
737
|
+
};
|
|
738
|
+
if (el.closest(MASK_SELECTOR)) return map;
|
|
739
|
+
const chars = [];
|
|
740
|
+
let space = true;
|
|
741
|
+
const push = (c, node, offset) => {
|
|
742
|
+
chars.push(c);
|
|
743
|
+
map.node.push(node);
|
|
744
|
+
map.offset.push(offset);
|
|
745
|
+
};
|
|
746
|
+
const gap = () => {
|
|
747
|
+
if (space || !map.nodes.length) return;
|
|
748
|
+
const last = map.nodes.length - 1;
|
|
749
|
+
push(" ", last, map.nodes[last].length);
|
|
750
|
+
space = true;
|
|
751
|
+
};
|
|
752
|
+
const walk = (node) => {
|
|
753
|
+
if (chars.length >= MAX_TEXT) return;
|
|
754
|
+
if (node.nodeType === 3) {
|
|
755
|
+
const value = node.nodeValue ?? "";
|
|
756
|
+
if (!value) return;
|
|
757
|
+
const index = map.nodes.push(node) - 1;
|
|
758
|
+
for (let i = 0; i < value.length && chars.length < MAX_TEXT; i++) {
|
|
759
|
+
const c = value[i];
|
|
760
|
+
if (c === " " || c === "\n" || c === " " || c === "\r" || c === "\f") {
|
|
761
|
+
if (!space) push(" ", index, i);
|
|
762
|
+
space = true;
|
|
763
|
+
} else {
|
|
764
|
+
push(c, index, i);
|
|
765
|
+
space = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (node.nodeType !== 1) return;
|
|
771
|
+
const el = node;
|
|
772
|
+
const tag = el.tagName.toUpperCase();
|
|
773
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
774
|
+
if (el.hasAttribute("data-nuni-mask") || el.hasAttribute("data-nuni")) return;
|
|
775
|
+
const block = BLOCK_TAGS.has(tag);
|
|
776
|
+
if (block) gap();
|
|
777
|
+
for (const child of Array.from(el.childNodes)) walk(child);
|
|
778
|
+
if (block) gap();
|
|
779
|
+
};
|
|
780
|
+
walk(el);
|
|
781
|
+
while (chars.length && chars[chars.length - 1] === " ") {
|
|
782
|
+
chars.pop();
|
|
783
|
+
map.node.pop();
|
|
784
|
+
map.offset.pop();
|
|
785
|
+
}
|
|
786
|
+
map.text = chars.join("");
|
|
787
|
+
return map;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Where a DOM boundary point falls in the element's text: the index of the
|
|
791
|
+
* first character at or after it.
|
|
792
|
+
*/
|
|
793
|
+
function textOffset(map, container, offset) {
|
|
794
|
+
if (!map.text.length) return 0;
|
|
795
|
+
const point = (container.ownerDocument ?? container).createRange();
|
|
796
|
+
point.setStart(container, offset);
|
|
797
|
+
point.collapse(true);
|
|
798
|
+
let lo = 0;
|
|
799
|
+
let hi = map.text.length;
|
|
800
|
+
try {
|
|
801
|
+
while (lo < hi) {
|
|
802
|
+
const mid = lo + hi >> 1;
|
|
803
|
+
const node = map.nodes[map.node[mid]];
|
|
804
|
+
if (point.comparePoint(node, map.offset[mid]) < 0) lo = mid + 1;
|
|
805
|
+
else hi = mid;
|
|
806
|
+
}
|
|
807
|
+
} catch {
|
|
808
|
+
return -1;
|
|
809
|
+
}
|
|
810
|
+
return lo;
|
|
811
|
+
}
|
|
812
|
+
/** A Range over characters [start, end) of the element's text. */
|
|
813
|
+
function rangeFor(map, start, end) {
|
|
814
|
+
const first = map.nodes[map.node[start]];
|
|
815
|
+
const last = map.nodes[map.node[end - 1]];
|
|
816
|
+
const range = first.ownerDocument.createRange();
|
|
817
|
+
range.setStart(first, map.offset[start]);
|
|
818
|
+
range.setEnd(last, Math.min(last.length, map.offset[end - 1] + 1));
|
|
819
|
+
return range;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The element a text selection is anchored to: the closest element holding
|
|
823
|
+
* all of it, out of inline formatting (a <strong>, a link) so it is the
|
|
824
|
+
* paragraph or heading, which is easier to find again.
|
|
825
|
+
*/
|
|
826
|
+
function quoteContainer(range) {
|
|
827
|
+
let node = range.commonAncestorContainer;
|
|
828
|
+
if (node.nodeType !== 1) node = node.parentNode;
|
|
829
|
+
if (!node || node.nodeType !== 1) return null;
|
|
830
|
+
let el = node;
|
|
831
|
+
const body = el.ownerDocument.body;
|
|
832
|
+
while (INLINE_TAGS.has(el.tagName.toUpperCase()) && el.parentElement && el.parentElement !== body) el = el.parentElement;
|
|
833
|
+
return el;
|
|
834
|
+
}
|
|
835
|
+
/** The quoted words of a selection inside `el`, with context around them. */
|
|
836
|
+
function captureQuote(range, el) {
|
|
837
|
+
const map = textMap(el);
|
|
838
|
+
let start = textOffset(map, range.startContainer, range.startOffset);
|
|
839
|
+
let end = textOffset(map, range.endContainer, range.endOffset);
|
|
840
|
+
if (start < 0 || end < 0) return null;
|
|
841
|
+
const { text } = map;
|
|
842
|
+
while (start < end && text[start] === " ") start++;
|
|
843
|
+
while (end > start && text[end - 1] === " ") end--;
|
|
844
|
+
if (end <= start) return null;
|
|
845
|
+
const exact = text.slice(start, Math.min(end, start + LIMITS.quoteMaxLength));
|
|
846
|
+
const after = start + exact.length;
|
|
847
|
+
return {
|
|
848
|
+
exact,
|
|
849
|
+
prefix: text.slice(Math.max(0, start - LIMITS.quoteContextLength), start),
|
|
850
|
+
suffix: text.slice(after, after + LIMITS.quoteContextLength)
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Find a quote again inside `el`. When it is not there (the sentence moved
|
|
855
|
+
* to the next paragraph), look in the parent and grandparent, but only take
|
|
856
|
+
* a match there whose surrounding text still agrees.
|
|
857
|
+
*/
|
|
858
|
+
function findQuote(el, quote) {
|
|
859
|
+
const own = findIn(el, quote, 0);
|
|
860
|
+
if (own) return own;
|
|
861
|
+
let node = el.parentElement;
|
|
862
|
+
const body = el.ownerDocument.body;
|
|
863
|
+
for (let i = 0; node && node !== body && i < 2; i++) {
|
|
864
|
+
const hit = findIn(node, quote, .6);
|
|
865
|
+
if (hit) return hit;
|
|
866
|
+
node = node.parentElement;
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
function affixSimilarity(actual, expected) {
|
|
871
|
+
if (!expected) return actual ? .8 : 1;
|
|
872
|
+
return textSimilarity(actual, expected);
|
|
873
|
+
}
|
|
874
|
+
/** How well the text around a match agrees with the quote's context. */
|
|
875
|
+
function contextScore(text, start, end, quote) {
|
|
876
|
+
const before = text.slice(Math.max(0, start - quote.prefix.length), start);
|
|
877
|
+
const after = text.slice(end, end + quote.suffix.length);
|
|
878
|
+
return (affixSimilarity(before.trimStart(), quote.prefix.trimStart()) + affixSimilarity(after.trimEnd(), quote.suffix.trimEnd())) / 2;
|
|
879
|
+
}
|
|
880
|
+
function findIn(el, quote, minContext) {
|
|
881
|
+
const { exact } = quote;
|
|
882
|
+
if (!exact) return null;
|
|
883
|
+
const map = textMap(el);
|
|
884
|
+
const { text } = map;
|
|
885
|
+
if (!text) return null;
|
|
886
|
+
let best = -1;
|
|
887
|
+
let bestScore = -1;
|
|
888
|
+
for (let i = text.indexOf(exact); i >= 0; i = text.indexOf(exact, i + 1)) {
|
|
889
|
+
const score = contextScore(text, i, i + exact.length, quote);
|
|
890
|
+
if (score > bestScore) {
|
|
891
|
+
best = i;
|
|
892
|
+
bestScore = score;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (best >= 0 && bestScore >= minContext) return {
|
|
896
|
+
range: rangeFor(map, best, best + exact.length),
|
|
897
|
+
exact: true
|
|
898
|
+
};
|
|
899
|
+
if (exact.length < 8 || text.length > MAX_FUZZY_TEXT) return null;
|
|
900
|
+
const fuzzy = closestSpan(text, quote);
|
|
901
|
+
if (!fuzzy) return null;
|
|
902
|
+
const context = contextScore(text, fuzzy.start, fuzzy.end, quote);
|
|
903
|
+
if (fuzzy.score < FUZZY_MIN || context < Math.max(minContext, .3)) return null;
|
|
904
|
+
return {
|
|
905
|
+
range: rangeFor(map, fuzzy.start, fuzzy.end),
|
|
906
|
+
exact: false
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/** Word-aligned spans of `text` scored by similarity to the quote. */
|
|
910
|
+
function closestSpan(text, quote) {
|
|
911
|
+
const word = (c) => c !== void 0 && WORD.test(c);
|
|
912
|
+
const starts = [];
|
|
913
|
+
const ends = [];
|
|
914
|
+
for (let i = 0; i < text.length; i++) {
|
|
915
|
+
const c = text[i];
|
|
916
|
+
if (c === " ") continue;
|
|
917
|
+
const prev = text[i - 1];
|
|
918
|
+
const next = text[i + 1];
|
|
919
|
+
if (prev === void 0 || prev === " " || word(c) && !word(prev)) starts.push(i);
|
|
920
|
+
if (next === void 0 || next === " " || word(c) && !word(next)) ends.push(i + 1);
|
|
921
|
+
}
|
|
922
|
+
const length = quote.exact.length;
|
|
923
|
+
let best = null;
|
|
924
|
+
let e = 0;
|
|
925
|
+
for (const start of starts) {
|
|
926
|
+
while (e < ends.length && ends[e] <= start) e++;
|
|
927
|
+
for (let j = e; j < ends.length; j++) {
|
|
928
|
+
const span = ends[j] - start;
|
|
929
|
+
if (span < length * .7) continue;
|
|
930
|
+
if (span > length * 1.3) break;
|
|
931
|
+
const score = textSimilarity(text.slice(start, ends[j]), quote.exact);
|
|
932
|
+
if (!best || score > best.score) best = {
|
|
933
|
+
start,
|
|
934
|
+
end: ends[j],
|
|
935
|
+
score
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return best;
|
|
940
|
+
}
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region ../anchor/src/scope.ts
|
|
943
|
+
function isShadowRoot(node) {
|
|
944
|
+
return Boolean(node && node.nodeType === 11 && "host" in node);
|
|
945
|
+
}
|
|
946
|
+
/** The shadow root or document the element lives in. */
|
|
947
|
+
function scopeRootOf(el) {
|
|
948
|
+
const root = el.getRootNode();
|
|
949
|
+
return isShadowRoot(root) ? root : el.ownerDocument;
|
|
950
|
+
}
|
|
951
|
+
function scopeDocument(root) {
|
|
952
|
+
return isShadowRoot(root) ? root.ownerDocument : root;
|
|
953
|
+
}
|
|
954
|
+
/** The <iframe> showing this document, when the parent is same-origin. */
|
|
955
|
+
function frameElementOf(doc) {
|
|
956
|
+
try {
|
|
957
|
+
return doc.defaultView?.frameElement ?? null;
|
|
958
|
+
} catch {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/** A same-origin iframe's document, or null (cross-origin, not loaded). */
|
|
963
|
+
function frameDocument(el) {
|
|
964
|
+
if (el.tagName !== "IFRAME" && el.tagName !== "FRAME") return null;
|
|
965
|
+
try {
|
|
966
|
+
return el.contentDocument;
|
|
967
|
+
} catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Shadow hosts and iframes between the page's document and the element,
|
|
973
|
+
* outermost first.
|
|
974
|
+
*/
|
|
975
|
+
function scopeChain(el, top) {
|
|
976
|
+
const chain = [];
|
|
977
|
+
let node = el;
|
|
978
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
979
|
+
const root = node.getRootNode();
|
|
980
|
+
if (isShadowRoot(root)) {
|
|
981
|
+
chain.unshift({
|
|
982
|
+
kind: "shadow",
|
|
983
|
+
host: root.host
|
|
984
|
+
});
|
|
985
|
+
node = root.host;
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (node.ownerDocument === top) break;
|
|
989
|
+
const frame = frameElementOf(node.ownerDocument);
|
|
990
|
+
if (!frame) break;
|
|
991
|
+
chain.unshift({
|
|
992
|
+
kind: "frame",
|
|
993
|
+
host: frame
|
|
994
|
+
});
|
|
995
|
+
node = frame;
|
|
996
|
+
}
|
|
997
|
+
return chain;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* The element's box in the page's viewport: getBoundingClientRect plus the
|
|
1001
|
+
* offset of every iframe it is inside.
|
|
1002
|
+
*/
|
|
1003
|
+
function viewportRect(el, top) {
|
|
1004
|
+
const r = el.getBoundingClientRect();
|
|
1005
|
+
let left = r.left;
|
|
1006
|
+
let topPx = r.top;
|
|
1007
|
+
let clipped = false;
|
|
1008
|
+
let doc = el.ownerDocument;
|
|
1009
|
+
for (let depth = 0; depth < 8 && doc !== top; depth++) {
|
|
1010
|
+
const frame = frameElementOf(doc);
|
|
1011
|
+
if (!frame) break;
|
|
1012
|
+
const view = doc.defaultView;
|
|
1013
|
+
if (view && (left + r.width < 0 || topPx + r.height < 0 || left > view.innerWidth || topPx > view.innerHeight)) clipped = true;
|
|
1014
|
+
const f = frame.getBoundingClientRect();
|
|
1015
|
+
left += f.left + frame.clientLeft;
|
|
1016
|
+
topPx += f.top + frame.clientTop;
|
|
1017
|
+
doc = frame.ownerDocument;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
left,
|
|
1021
|
+
top: topPx,
|
|
1022
|
+
width: r.width,
|
|
1023
|
+
height: r.height,
|
|
1024
|
+
clipped
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
//#endregion
|
|
516
1028
|
//#region ../anchor/src/stable.ts
|
|
517
1029
|
/**
|
|
518
1030
|
* Heuristics that decide whether an id, class or attribute is a stable,
|
|
@@ -666,7 +1178,7 @@ function nthOfType(el) {
|
|
|
666
1178
|
return index;
|
|
667
1179
|
}
|
|
668
1180
|
function countOfType(el) {
|
|
669
|
-
const parent = el.
|
|
1181
|
+
const parent = el.parentNode;
|
|
670
1182
|
if (!parent) return 1;
|
|
671
1183
|
let count = 0;
|
|
672
1184
|
for (const child of Array.from(parent.children)) if (child.tagName === el.tagName) count++;
|
|
@@ -679,21 +1191,23 @@ function queryCount(root, selector) {
|
|
|
679
1191
|
return Infinity;
|
|
680
1192
|
}
|
|
681
1193
|
}
|
|
682
|
-
function isUniqueId(
|
|
683
|
-
return queryCount(
|
|
1194
|
+
function isUniqueId(root, id) {
|
|
1195
|
+
return queryCount(root, `#${cssEscape(id)}`) === 1;
|
|
684
1196
|
}
|
|
685
1197
|
/**
|
|
686
1198
|
* Structural path: nth-of-type chain from the nearest ancestor with a
|
|
687
1199
|
* stable unique id (or from <html>). Always resolvable when the DOM is
|
|
688
|
-
* unchanged; the last-resort selector.
|
|
1200
|
+
* unchanged; the last-resort selector. Inside a shadow root it starts at
|
|
1201
|
+
* the root's top-level element instead of <html>.
|
|
689
1202
|
*/
|
|
690
1203
|
function buildPath(el) {
|
|
1204
|
+
const root = scopeRootOf(el);
|
|
691
1205
|
const doc = el.ownerDocument;
|
|
692
1206
|
const parts = [];
|
|
693
1207
|
let node = el;
|
|
694
1208
|
while (node && node !== doc.documentElement) {
|
|
695
1209
|
const id = stableIdOf(node);
|
|
696
|
-
if (id && node !== el && isUniqueId(
|
|
1210
|
+
if (id && node !== el && isUniqueId(root, id)) {
|
|
697
1211
|
parts.unshift(`#${cssEscape(id)}`);
|
|
698
1212
|
return parts.join(" > ");
|
|
699
1213
|
}
|
|
@@ -701,8 +1215,28 @@ function buildPath(el) {
|
|
|
701
1215
|
parts.unshift(countOfType(node) > 1 ? `${tag}:nth-of-type(${nthOfType(node)})` : tag);
|
|
702
1216
|
node = node.parentElement;
|
|
703
1217
|
}
|
|
1218
|
+
if (isShadowRoot(root)) return parts.join(" > ");
|
|
704
1219
|
return parts.length ? `html > ${parts.join(" > ")}` : "html";
|
|
705
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The element a path points to in a scope. Shadow paths are matched from
|
|
1223
|
+
* the root's top-level elements, like document paths are from <html>.
|
|
1224
|
+
*/
|
|
1225
|
+
function queryPath(root, path) {
|
|
1226
|
+
let matches;
|
|
1227
|
+
try {
|
|
1228
|
+
matches = Array.from(root.querySelectorAll(path)).slice(0, 50);
|
|
1229
|
+
} catch {
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
if (!isShadowRoot(root) || path.startsWith("#")) return matches[0] ?? null;
|
|
1233
|
+
const depth = path.split(" > ").length;
|
|
1234
|
+
return matches.find((el) => {
|
|
1235
|
+
let node = el;
|
|
1236
|
+
for (let i = 1; i < depth && node; i++) node = node.parentElement;
|
|
1237
|
+
return node?.parentNode === root;
|
|
1238
|
+
}) ?? null;
|
|
1239
|
+
}
|
|
706
1240
|
function describeSegment(el, options) {
|
|
707
1241
|
const tag = tagOf(el);
|
|
708
1242
|
const testId = testIdOf(el, options);
|
|
@@ -731,8 +1265,9 @@ function describeSegment(el, options) {
|
|
|
731
1265
|
*/
|
|
732
1266
|
function buildCssSelector(el, options = {}) {
|
|
733
1267
|
const doc = el.ownerDocument;
|
|
1268
|
+
const root = scopeRootOf(el);
|
|
734
1269
|
const id = stableIdOf(el);
|
|
735
|
-
if (id && isUniqueId(
|
|
1270
|
+
if (id && isUniqueId(root, id)) return `#${cssEscape(id)}`;
|
|
736
1271
|
const segments = [];
|
|
737
1272
|
let node = el;
|
|
738
1273
|
for (let depth = 0; node && depth < 6; depth++) {
|
|
@@ -741,92 +1276,25 @@ function buildCssSelector(el, options = {}) {
|
|
|
741
1276
|
break;
|
|
742
1277
|
}
|
|
743
1278
|
const nodeId = stableIdOf(node);
|
|
744
|
-
if (depth > 0 && nodeId && isUniqueId(
|
|
1279
|
+
if (depth > 0 && nodeId && isUniqueId(root, nodeId)) {
|
|
745
1280
|
segments.unshift(`#${cssEscape(nodeId)}`);
|
|
746
1281
|
const selector = segments.join(" > ");
|
|
747
|
-
if (queryCount(
|
|
1282
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
748
1283
|
break;
|
|
749
1284
|
}
|
|
750
1285
|
segments.unshift(describeSegment(node, options));
|
|
751
1286
|
let selector = segments.join(" > ");
|
|
752
|
-
if (queryCount(
|
|
1287
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
753
1288
|
const parent = node.parentElement;
|
|
754
1289
|
if (parent && queryCount(parent, `:scope > ${segments[0]}`) > 1) {
|
|
755
1290
|
segments[0] = `${segments[0]}:nth-of-type(${nthOfType(node)})`;
|
|
756
1291
|
selector = segments.join(" > ");
|
|
757
|
-
if (queryCount(
|
|
1292
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
758
1293
|
}
|
|
759
1294
|
node = node.parentElement;
|
|
760
1295
|
}
|
|
761
1296
|
const selector = segments.join(" > ");
|
|
762
|
-
return queryCount(
|
|
763
|
-
}
|
|
764
|
-
//#endregion
|
|
765
|
-
//#region ../anchor/src/text.ts
|
|
766
|
-
const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
767
|
-
"SCRIPT",
|
|
768
|
-
"STYLE",
|
|
769
|
-
"NOSCRIPT",
|
|
770
|
-
"TEMPLATE",
|
|
771
|
-
"SVG"
|
|
772
|
-
]);
|
|
773
|
-
function normalizeText(value) {
|
|
774
|
-
return value.replace(/\s+/g, " ").trim();
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Visible-ish text of an element, whitespace-normalized and truncated.
|
|
778
|
-
* Areas marked data-nuni-mask count as empty, both when capturing and when
|
|
779
|
-
* resolving, so their (private) text is never stored and matching stays
|
|
780
|
-
* consistent.
|
|
781
|
-
*/
|
|
782
|
-
function elementText(el, max = 120) {
|
|
783
|
-
if (el.closest(MASK_SELECTOR)) return "";
|
|
784
|
-
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
|
|
785
|
-
let out = "";
|
|
786
|
-
const walk = (node) => {
|
|
787
|
-
if (out.length > max * 2) return;
|
|
788
|
-
if (node.nodeType === 3) {
|
|
789
|
-
out += node.nodeValue ?? "";
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
if (node.nodeType !== 1) return;
|
|
793
|
-
const tag = node.tagName.toUpperCase();
|
|
794
|
-
if (SKIP_TEXT_TAGS.has(tag)) return;
|
|
795
|
-
if (node.hasAttribute("data-nuni-mask")) return;
|
|
796
|
-
if (tag === "IMG") {
|
|
797
|
-
out += " " + (node.getAttribute("alt") ?? "") + " ";
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
for (const child of Array.from(node.childNodes)) walk(child);
|
|
801
|
-
if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
|
|
802
|
-
};
|
|
803
|
-
walk(el);
|
|
804
|
-
return normalizeText(out).slice(0, max);
|
|
805
|
-
}
|
|
806
|
-
function bigrams(value) {
|
|
807
|
-
const s = value.toLowerCase();
|
|
808
|
-
const map = /* @__PURE__ */ new Map();
|
|
809
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
810
|
-
const gram = s.slice(i, i + 2);
|
|
811
|
-
map.set(gram, (map.get(gram) ?? 0) + 1);
|
|
812
|
-
}
|
|
813
|
-
return map;
|
|
814
|
-
}
|
|
815
|
-
/** Sørensen-Dice coefficient on character bigrams, 0..1. */
|
|
816
|
-
function textSimilarity(a, b) {
|
|
817
|
-
if (a === b) return 1;
|
|
818
|
-
const na = a.toLowerCase();
|
|
819
|
-
const nb = b.toLowerCase();
|
|
820
|
-
if (na === nb) return 1;
|
|
821
|
-
if (na.length < 2 || nb.length < 2) return 0;
|
|
822
|
-
const ga = bigrams(na);
|
|
823
|
-
const gb = bigrams(nb);
|
|
824
|
-
let overlap = 0;
|
|
825
|
-
for (const [gram, count] of ga) {
|
|
826
|
-
const other = gb.get(gram);
|
|
827
|
-
if (other) overlap += Math.min(count, other);
|
|
828
|
-
}
|
|
829
|
-
return 2 * overlap / (na.length - 1 + (nb.length - 1));
|
|
1297
|
+
return queryCount(root, selector) === 1 ? selector : void 0;
|
|
830
1298
|
}
|
|
831
1299
|
//#endregion
|
|
832
1300
|
//#region ../anchor/src/capture.ts
|
|
@@ -835,7 +1303,7 @@ function capturedAttributes(el, options) {
|
|
|
835
1303
|
if (el.closest(MASK_SELECTOR)) return attrs;
|
|
836
1304
|
for (const name of CAPTURED_ATTRIBUTES) {
|
|
837
1305
|
if (!attributeAllowed(name, options)) continue;
|
|
838
|
-
if (name === "value" && !(el
|
|
1306
|
+
if (name === "value" && !(el.tagName === "BUTTON" || el.getAttribute("type") === "submit")) continue;
|
|
839
1307
|
const value = el.getAttribute(name);
|
|
840
1308
|
if (value) attrs[name] = value.slice(0, 200);
|
|
841
1309
|
}
|
|
@@ -863,8 +1331,22 @@ function ancestorsOf(el) {
|
|
|
863
1331
|
/**
|
|
864
1332
|
* Record everything needed to find `el` again later.
|
|
865
1333
|
* `point` is the click position in viewport (client) coordinates.
|
|
1334
|
+
*
|
|
1335
|
+
* An element inside open shadow roots or same-origin iframes also records
|
|
1336
|
+
* each host and iframe on the way (`scope`), captured the same way, so it
|
|
1337
|
+
* can be found again level by level.
|
|
866
1338
|
*/
|
|
867
1339
|
function captureAnchor(el, point, options = {}) {
|
|
1340
|
+
const anchor = captureLocal(el, point, options);
|
|
1341
|
+
const chain = scopeChain(el, options.document ?? (typeof document === "undefined" ? el.ownerDocument : document));
|
|
1342
|
+
if (chain.length) anchor.scope = chain.map(({ kind, host }) => ({
|
|
1343
|
+
kind,
|
|
1344
|
+
host: captureLocal(host, void 0, options)
|
|
1345
|
+
}));
|
|
1346
|
+
return anchor;
|
|
1347
|
+
}
|
|
1348
|
+
/** The anchor of `el` within its own scope (document or shadow root). */
|
|
1349
|
+
function captureLocal(el, point, options) {
|
|
868
1350
|
const doc = el.ownerDocument;
|
|
869
1351
|
const rect = documentRect(el);
|
|
870
1352
|
const viewport = viewportInfo(doc);
|
|
@@ -880,7 +1362,7 @@ function captureAnchor(el, point, options = {}) {
|
|
|
880
1362
|
const testId = testIdOf(el, options);
|
|
881
1363
|
const css = buildCssSelector(el, options);
|
|
882
1364
|
const role = el.getAttribute("role") ?? void 0;
|
|
883
|
-
const parent = el.
|
|
1365
|
+
const parent = el.parentNode;
|
|
884
1366
|
const siblings = parent ? Array.from(parent.children) : [el];
|
|
885
1367
|
const anchor = {
|
|
886
1368
|
v: 1,
|
|
@@ -907,8 +1389,135 @@ function captureAnchor(el, point, options = {}) {
|
|
|
907
1389
|
if (componentName) anchor.componentName = componentName;
|
|
908
1390
|
return anchor;
|
|
909
1391
|
}
|
|
1392
|
+
/**
|
|
1393
|
+
* A text comment: the anchor of the paragraph (or heading, cell...) holding
|
|
1394
|
+
* the selected text, plus the words themselves and the text around them.
|
|
1395
|
+
* Null when the selection holds no text.
|
|
1396
|
+
*/
|
|
1397
|
+
function captureSelection(range, options = {}) {
|
|
1398
|
+
const element = quoteContainer(range);
|
|
1399
|
+
if (!element) return null;
|
|
1400
|
+
const quote = captureQuote(range, element);
|
|
1401
|
+
if (!quote) return null;
|
|
1402
|
+
const first = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
|
1403
|
+
const anchor = captureAnchor(element, {
|
|
1404
|
+
x: first.left,
|
|
1405
|
+
y: first.top + first.height / 2
|
|
1406
|
+
}, options);
|
|
1407
|
+
anchor.quote = quote;
|
|
1408
|
+
return {
|
|
1409
|
+
element,
|
|
1410
|
+
anchor
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* The element an area comment belongs to: the smallest one that covers the
|
|
1415
|
+
* whole box (in viewport coordinates).
|
|
1416
|
+
*/
|
|
1417
|
+
function areaContainer(doc, box, isIgnored = () => false) {
|
|
1418
|
+
const right = box.left + box.width;
|
|
1419
|
+
const bottom = box.top + box.height;
|
|
1420
|
+
const corners = [
|
|
1421
|
+
[box.left + 1, box.top + 1],
|
|
1422
|
+
[right - 1, box.top + 1],
|
|
1423
|
+
[box.left + 1, bottom - 1],
|
|
1424
|
+
[right - 1, bottom - 1]
|
|
1425
|
+
];
|
|
1426
|
+
const ignored = (el) => isIgnored(el) || el.closest("[data-nuni]") !== null;
|
|
1427
|
+
let common = null;
|
|
1428
|
+
for (const [x, y] of corners) {
|
|
1429
|
+
const hit = doc.elementsFromPoint(x, y).find((el) => !ignored(el));
|
|
1430
|
+
if (hit) common = common ? commonAncestor(common, hit) : hit;
|
|
1431
|
+
}
|
|
1432
|
+
let el = common ?? doc.body;
|
|
1433
|
+
while (el.parentElement && el !== doc.body) {
|
|
1434
|
+
const r = el.getBoundingClientRect();
|
|
1435
|
+
if (r.left <= box.left + 1 && r.top <= box.top + 1 && r.right >= right - 1 && r.bottom >= bottom - 1) break;
|
|
1436
|
+
el = el.parentElement;
|
|
1437
|
+
}
|
|
1438
|
+
return el;
|
|
1439
|
+
}
|
|
1440
|
+
function commonAncestor(a, b) {
|
|
1441
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1442
|
+
for (let n = a; n; n = n.parentElement) seen.add(n);
|
|
1443
|
+
for (let n = b; n; n = n.parentElement) if (seen.has(n)) return n;
|
|
1444
|
+
return a.ownerDocument.body;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* An area comment: the covering element's anchor plus the box as fractions
|
|
1448
|
+
* of that element's size, so it scales with the layout.
|
|
1449
|
+
*/
|
|
1450
|
+
function captureArea(el, box, options = {}) {
|
|
1451
|
+
const anchor = captureAnchor(el, {
|
|
1452
|
+
x: box.left + box.width,
|
|
1453
|
+
y: box.top
|
|
1454
|
+
}, options);
|
|
1455
|
+
const r = el.getBoundingClientRect();
|
|
1456
|
+
const x = r.width ? clamp01((box.left - r.left) / r.width) : 0;
|
|
1457
|
+
const y = r.height ? clamp01((box.top - r.top) / r.height) : 0;
|
|
1458
|
+
anchor.region = {
|
|
1459
|
+
x,
|
|
1460
|
+
y,
|
|
1461
|
+
w: r.width ? Math.min(1 - x, box.width / r.width) : 1,
|
|
1462
|
+
h: r.height ? Math.min(1 - y, box.height / r.height) : 1
|
|
1463
|
+
};
|
|
1464
|
+
return anchor;
|
|
1465
|
+
}
|
|
910
1466
|
//#endregion
|
|
911
1467
|
//#region ../anchor/src/resolve.ts
|
|
1468
|
+
function createResolveCache() {
|
|
1469
|
+
return {
|
|
1470
|
+
byTag: /* @__PURE__ */ new Map(),
|
|
1471
|
+
elements: /* @__PURE__ */ new Map(),
|
|
1472
|
+
text: /* @__PURE__ */ new WeakMap(),
|
|
1473
|
+
content: /* @__PURE__ */ new WeakMap(),
|
|
1474
|
+
words: /* @__PURE__ */ new WeakMap()
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
function textOf(el, max, cache) {
|
|
1478
|
+
if (!cache) return elementText(el, max);
|
|
1479
|
+
let byMax = cache.text.get(el);
|
|
1480
|
+
if (!byMax) cache.text.set(el, byMax = /* @__PURE__ */ new Map());
|
|
1481
|
+
let text = byMax.get(max);
|
|
1482
|
+
if (text === void 0) byMax.set(max, text = elementText(el, max));
|
|
1483
|
+
return text;
|
|
1484
|
+
}
|
|
1485
|
+
function contentOf(el, cache) {
|
|
1486
|
+
if (!cache) return el.textContent ?? "";
|
|
1487
|
+
let content = cache.content.get(el);
|
|
1488
|
+
if (content === void 0) cache.content.set(el, content = el.textContent ?? "");
|
|
1489
|
+
return content;
|
|
1490
|
+
}
|
|
1491
|
+
/** All of an element's text with whitespace collapsed, for quote checks. */
|
|
1492
|
+
function wordsOf(el, cache) {
|
|
1493
|
+
let words = cache?.words.get(el);
|
|
1494
|
+
if (words === void 0) {
|
|
1495
|
+
words = normalizeText(contentOf(el, cache));
|
|
1496
|
+
cache?.words.set(el, words);
|
|
1497
|
+
}
|
|
1498
|
+
return words;
|
|
1499
|
+
}
|
|
1500
|
+
function elementsByTag(root, tag, cache) {
|
|
1501
|
+
const find = () => isShadowRoot(root) ? root.querySelectorAll(tag) : root.getElementsByTagName(tag);
|
|
1502
|
+
if (!cache) return find();
|
|
1503
|
+
let tags = cache.byTag.get(root);
|
|
1504
|
+
if (!tags) cache.byTag.set(root, tags = /* @__PURE__ */ new Map());
|
|
1505
|
+
let list = tags.get(tag);
|
|
1506
|
+
if (!list) tags.set(tag, list = Array.from(find()));
|
|
1507
|
+
return list;
|
|
1508
|
+
}
|
|
1509
|
+
/** Every element in the scope, in document order, up to `limit`. */
|
|
1510
|
+
function allElements(root, limit, cache) {
|
|
1511
|
+
const cached = cache?.elements.get(root);
|
|
1512
|
+
if (cached) return cached;
|
|
1513
|
+
const doc = scopeDocument(root);
|
|
1514
|
+
const start = isShadowRoot(root) ? root : doc.body ?? doc.documentElement;
|
|
1515
|
+
const walker = doc.createTreeWalker(start, 1);
|
|
1516
|
+
const out = [];
|
|
1517
|
+
for (let n = walker.nextNode(); n && out.length < limit; n = walker.nextNode()) out.push(n);
|
|
1518
|
+
cache?.elements.set(root, out);
|
|
1519
|
+
return out;
|
|
1520
|
+
}
|
|
912
1521
|
const THRESHOLD = {
|
|
913
1522
|
exact: .9,
|
|
914
1523
|
high: .72,
|
|
@@ -976,7 +1585,7 @@ const IDENTIFYING_ATTRS = /* @__PURE__ */ new Set([
|
|
|
976
1585
|
"alt",
|
|
977
1586
|
"title"
|
|
978
1587
|
]);
|
|
979
|
-
function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
1588
|
+
function scoreCandidate(anchor, el, doc, ctx, options, cache) {
|
|
980
1589
|
let total = 0;
|
|
981
1590
|
let weights = 0;
|
|
982
1591
|
const add = (weight, value) => {
|
|
@@ -1023,9 +1632,11 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
1023
1632
|
}
|
|
1024
1633
|
if (anchor.role) add(.5, el.getAttribute("role") === anchor.role ? 1 : 0);
|
|
1025
1634
|
if (anchor.componentName) add(.5, reactComponentName(el) === anchor.componentName ? 1 : 0);
|
|
1026
|
-
const text =
|
|
1635
|
+
const text = textOf(el, 120, cache);
|
|
1027
1636
|
const textSim = anchor.text ? textSimilarity(text, anchor.text) : text ? .3 : 1;
|
|
1028
1637
|
add(anchor.text ? 3 : 1, textSim);
|
|
1638
|
+
const quoteHit = anchor.quote ? wordsOf(el, cache).includes(anchor.quote.exact) : null;
|
|
1639
|
+
if (quoteHit !== null) add(3, quoteHit ? 1 : 0);
|
|
1029
1640
|
let ancTotal = 0;
|
|
1030
1641
|
let ancWeights = 0;
|
|
1031
1642
|
let context = null;
|
|
@@ -1040,18 +1651,19 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
1040
1651
|
else s += .35 * (nodeId ? .5 : 1);
|
|
1041
1652
|
s += .25 * jaccard(stableClasses(node), expected.classes);
|
|
1042
1653
|
ancTotal += weight * s;
|
|
1043
|
-
if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(
|
|
1654
|
+
if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(textOf(node, 80, cache), expected.text);
|
|
1044
1655
|
node = node.parentElement;
|
|
1045
1656
|
});
|
|
1046
1657
|
if (ancWeights) add(1.5, ancTotal / ancWeights);
|
|
1047
1658
|
if (context !== null) add(2, context);
|
|
1048
|
-
const parent = el.
|
|
1659
|
+
const parent = el.parentNode;
|
|
1049
1660
|
const index = parent ? Array.prototype.indexOf.call(parent.children, el) : 0;
|
|
1050
1661
|
add(.5, Math.max(0, 1 - Math.abs(index - anchor.siblingIndex) / 3));
|
|
1051
1662
|
const geoWeight = Math.max(0, 1 - Math.abs(ctx.viewportRatio - 1) * 2);
|
|
1663
|
+
const r = el.getBoundingClientRect();
|
|
1664
|
+
const rendered = r.width + r.height > 0;
|
|
1052
1665
|
if (geoWeight > 0 && anchor.rect.w + anchor.rect.h > 0) {
|
|
1053
|
-
|
|
1054
|
-
if (r.width + r.height > 0) {
|
|
1666
|
+
if (rendered) {
|
|
1055
1667
|
const view = doc.defaultView;
|
|
1056
1668
|
const cx = r.left + (view?.scrollX ?? 0) + r.width / 2;
|
|
1057
1669
|
const cy = r.top + (view?.scrollY ?? 0) + r.height / 2;
|
|
@@ -1066,34 +1678,38 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
1066
1678
|
}
|
|
1067
1679
|
let score = weights ? total / weights : 0;
|
|
1068
1680
|
const structural = (cssHit || !anchor.selectors.css) && pathHit && identifyingAttrsMatch;
|
|
1069
|
-
|
|
1070
|
-
|
|
1681
|
+
const identity = ctx.quoteOnly === el ? Math.max(textSim, .9) : textSim;
|
|
1682
|
+
if (anchor.text.length >= 2 && identity < .999) {
|
|
1683
|
+
const factor = peerCount(el) > 0 ? .2 + .8 * identity * identity : .7 + .3 * identity;
|
|
1071
1684
|
score *= strong || structural ? Math.max(factor, .92) : factor;
|
|
1072
1685
|
}
|
|
1686
|
+
if (quoteHit === false) score *= .85;
|
|
1073
1687
|
return {
|
|
1074
1688
|
el,
|
|
1075
1689
|
score,
|
|
1076
1690
|
text: textSim,
|
|
1077
1691
|
context,
|
|
1078
|
-
strong: strong || structural
|
|
1692
|
+
strong: strong || structural,
|
|
1693
|
+
rendered
|
|
1079
1694
|
};
|
|
1080
1695
|
}
|
|
1081
|
-
function collectCandidates(anchor,
|
|
1696
|
+
function collectCandidates(anchor, root, options, cache) {
|
|
1697
|
+
const doc = scopeDocument(root);
|
|
1082
1698
|
const candidates = /* @__PURE__ */ new Set();
|
|
1083
1699
|
const ignored = (el) => el === doc.documentElement || (options.isIgnored?.(el) ?? false);
|
|
1084
1700
|
const add = (el) => {
|
|
1085
1701
|
if (el && !ignored(el)) candidates.add(el);
|
|
1086
1702
|
};
|
|
1087
|
-
if (anchor.selectors.id) add(
|
|
1703
|
+
if (anchor.selectors.id) add(isShadowRoot(root) ? safeQueryAll(root, `#${cssEscape(anchor.selectors.id)}`, 1)[0] : root.getElementById(anchor.selectors.id));
|
|
1088
1704
|
if (anchor.selectors.testId) {
|
|
1089
1705
|
const parts = splitTestId(anchor.selectors.testId);
|
|
1090
|
-
if (parts) safeQueryAll(
|
|
1706
|
+
if (parts) safeQueryAll(root, attrQuery(parts[0], parts[1])).forEach(add);
|
|
1091
1707
|
}
|
|
1092
|
-
safeQueryAll(
|
|
1093
|
-
const pathMatch =
|
|
1708
|
+
safeQueryAll(root, anchor.selectors.css).forEach(add);
|
|
1709
|
+
const pathMatch = queryPath(root, anchor.selectors.path);
|
|
1094
1710
|
add(pathMatch);
|
|
1095
1711
|
const needle = anchor.text.slice(0, 24).toLowerCase();
|
|
1096
|
-
const sameTag =
|
|
1712
|
+
const sameTag = elementsByTag(root, anchor.tag, cache);
|
|
1097
1713
|
const scanAll = sameTag.length <= MAX_SCAN;
|
|
1098
1714
|
for (let i = 0; i < sameTag.length && i < MAX_SCAN * 2; i++) {
|
|
1099
1715
|
const el = sameTag[i];
|
|
@@ -1101,20 +1717,16 @@ function collectCandidates(anchor, doc, options) {
|
|
|
1101
1717
|
add(el);
|
|
1102
1718
|
continue;
|
|
1103
1719
|
}
|
|
1104
|
-
const content = el.
|
|
1720
|
+
const content = contentOf(el, cache).toLowerCase();
|
|
1105
1721
|
if (needle && content.includes(needle)) add(el);
|
|
1106
1722
|
}
|
|
1107
1723
|
if (anchor.text.length >= 3) {
|
|
1108
1724
|
const exact = anchor.text.toLowerCase();
|
|
1109
|
-
const
|
|
1110
|
-
let count = 0;
|
|
1111
|
-
for (let n = walker.nextNode(); n && count < MAX_SCAN * 3; n = walker.nextNode()) {
|
|
1112
|
-
count++;
|
|
1113
|
-
const el = n;
|
|
1725
|
+
for (const el of allElements(root, MAX_SCAN * 3, cache)) {
|
|
1114
1726
|
if (el.tagName.toLowerCase() === anchor.tag) continue;
|
|
1115
|
-
const content = el
|
|
1727
|
+
const content = contentOf(el, cache);
|
|
1116
1728
|
if (!content || content.length > exact.length * 3 + 20) continue;
|
|
1117
|
-
if (
|
|
1729
|
+
if (textOf(el, 120, cache).toLowerCase() === exact) add(el);
|
|
1118
1730
|
}
|
|
1119
1731
|
}
|
|
1120
1732
|
return {
|
|
@@ -1122,44 +1734,104 @@ function collectCandidates(anchor, doc, options) {
|
|
|
1122
1734
|
pathMatch
|
|
1123
1735
|
};
|
|
1124
1736
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1737
|
+
const RANK = {
|
|
1738
|
+
lost: 0,
|
|
1739
|
+
low: 1,
|
|
1740
|
+
high: 2,
|
|
1741
|
+
exact: 3
|
|
1742
|
+
};
|
|
1743
|
+
/**
|
|
1744
|
+
* Find the element an anchor was captured on, in the current document.
|
|
1745
|
+
* Elements in shadow roots and iframes are found host by host first.
|
|
1746
|
+
* Pass one `cache` when resolving many anchors over the same DOM.
|
|
1747
|
+
*/
|
|
1748
|
+
function resolveAnchor(anchor, doc = document, options = {}, cache) {
|
|
1749
|
+
let root = doc;
|
|
1750
|
+
let floor = "exact";
|
|
1751
|
+
const lost = {
|
|
1752
|
+
element: null,
|
|
1753
|
+
confidence: "lost",
|
|
1754
|
+
score: 0
|
|
1755
|
+
};
|
|
1756
|
+
for (const step of anchor.scope ?? []) {
|
|
1757
|
+
const host = resolveIn(step.host, root, options, cache);
|
|
1758
|
+
if (!host.element) return lost;
|
|
1759
|
+
let confidence = host.confidence;
|
|
1760
|
+
if (step.host.text) {
|
|
1761
|
+
if (host.text < .9 && RANK[confidence] < RANK.high) return lost;
|
|
1762
|
+
if (host.text >= .999 && host.margin >= .1 && confidence === "low") confidence = "high";
|
|
1763
|
+
}
|
|
1764
|
+
const next = step.kind === "shadow" ? host.element.shadowRoot : frameDocument(host.element);
|
|
1765
|
+
if (!next) return lost;
|
|
1766
|
+
if (RANK[confidence] < RANK[floor]) floor = confidence;
|
|
1767
|
+
root = next;
|
|
1768
|
+
}
|
|
1769
|
+
const { text: _text, margin: _margin, ...result } = resolveIn(anchor, root, options, cache);
|
|
1770
|
+
if (result.element && RANK[floor] < RANK[result.confidence]) result.confidence = floor;
|
|
1771
|
+
if (result.element && anchor.quote) {
|
|
1772
|
+
const quote = findQuote(result.element, anchor.quote);
|
|
1773
|
+
if (quote) result.range = quote.range;
|
|
1774
|
+
if ((!quote || !quote.exact) && RANK[result.confidence] > RANK.low) result.confidence = "low";
|
|
1775
|
+
}
|
|
1776
|
+
return result;
|
|
1777
|
+
}
|
|
1778
|
+
function resolveIn(anchor, root, options, cache) {
|
|
1779
|
+
const doc = scopeDocument(root);
|
|
1780
|
+
if (!isShadowRoot(root) && (anchor.tag === "body" || anchor.tag === "html")) return {
|
|
1128
1781
|
element: doc.body,
|
|
1129
1782
|
confidence: "exact",
|
|
1130
|
-
score: 1
|
|
1783
|
+
score: 1,
|
|
1784
|
+
text: 1,
|
|
1785
|
+
margin: 1
|
|
1131
1786
|
};
|
|
1132
|
-
const { candidates, pathMatch } = collectCandidates(anchor,
|
|
1787
|
+
const { candidates, pathMatch } = collectCandidates(anchor, root, options, cache);
|
|
1133
1788
|
if (!candidates.size) return {
|
|
1134
1789
|
element: null,
|
|
1135
1790
|
confidence: "lost",
|
|
1136
|
-
score: 0
|
|
1791
|
+
score: 0,
|
|
1792
|
+
text: 0,
|
|
1793
|
+
margin: 0
|
|
1137
1794
|
};
|
|
1138
1795
|
const view = doc.defaultView;
|
|
1139
1796
|
const viewportRatio = anchor.viewport.w && view?.innerWidth ? view.innerWidth / anchor.viewport.w : 1;
|
|
1797
|
+
let quoteOnly = null;
|
|
1798
|
+
if (anchor.quote) {
|
|
1799
|
+
const exact = anchor.quote.exact;
|
|
1800
|
+
const hits = [...candidates].filter((el) => wordsOf(el, cache).includes(exact));
|
|
1801
|
+
const innermost = hits.filter((el) => !hits.some((other) => other !== el && el.contains(other)));
|
|
1802
|
+
if (innermost.length === 1) quoteOnly = innermost[0];
|
|
1803
|
+
}
|
|
1140
1804
|
const scored = [];
|
|
1141
1805
|
for (const el of candidates) scored.push(scoreCandidate(anchor, el, doc, {
|
|
1142
1806
|
pathMatch,
|
|
1143
|
-
viewportRatio
|
|
1144
|
-
|
|
1145
|
-
|
|
1807
|
+
viewportRatio,
|
|
1808
|
+
quoteOnly
|
|
1809
|
+
}, options, cache));
|
|
1810
|
+
const visibleOnly = anchor.rect.w + anchor.rect.h > 0 && scored.some((s) => s.rendered);
|
|
1811
|
+
const rival = (s) => !visibleOnly || s.rendered;
|
|
1812
|
+
if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag && rival(s)).length > 1) for (const s of scored) {
|
|
1146
1813
|
if (s.context === null || s.strong) continue;
|
|
1147
1814
|
s.score *= .2 + .8 * s.context * s.context;
|
|
1148
1815
|
}
|
|
1149
1816
|
scored.sort((a, b) => b.score - a.score);
|
|
1150
1817
|
const best = scored[0];
|
|
1151
|
-
const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el));
|
|
1818
|
+
const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el) && (!best.rendered || rival(s)));
|
|
1152
1819
|
if (best.score < THRESHOLD.low) return {
|
|
1153
1820
|
element: null,
|
|
1154
1821
|
confidence: "lost",
|
|
1155
|
-
score: best.score
|
|
1822
|
+
score: best.score,
|
|
1823
|
+
text: best.text,
|
|
1824
|
+
margin: 0
|
|
1156
1825
|
};
|
|
1157
1826
|
let confidence = best.score >= THRESHOLD.exact && best.text >= .999 ? "exact" : best.score >= THRESHOLD.high ? "high" : "low";
|
|
1158
|
-
|
|
1827
|
+
const margin = second ? best.score - second.score : 1;
|
|
1828
|
+
if (margin < AMBIGUITY_MARGIN) confidence = "low";
|
|
1159
1829
|
return {
|
|
1160
1830
|
element: best.el,
|
|
1161
1831
|
confidence,
|
|
1162
|
-
score: best.score
|
|
1832
|
+
score: best.score,
|
|
1833
|
+
text: best.text,
|
|
1834
|
+
margin
|
|
1163
1835
|
};
|
|
1164
1836
|
}
|
|
1165
1837
|
//#endregion
|
|
@@ -4615,6 +5287,13 @@ var NuniApi = class {
|
|
|
4615
5287
|
sessionToken
|
|
4616
5288
|
}, (comment) => cb(comment), () => {});
|
|
4617
5289
|
}
|
|
5290
|
+
/** Replies and reactions of one thread, live while its card is open. */
|
|
5291
|
+
onThread(commentId, cb) {
|
|
5292
|
+
return this.client.onUpdate(api.replies.listForComment, {
|
|
5293
|
+
publicId: this.config.project,
|
|
5294
|
+
commentId
|
|
5295
|
+
}, (thread) => cb(thread), () => {});
|
|
5296
|
+
}
|
|
4618
5297
|
/** One comment by id, for deep links outside the page listing window. */
|
|
4619
5298
|
getComment(id) {
|
|
4620
5299
|
return this.client.query(api.comments.getById, {
|
|
@@ -4640,6 +5319,25 @@ var NuniApi = class {
|
|
|
4640
5319
|
if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
|
|
4641
5320
|
return data.id;
|
|
4642
5321
|
}
|
|
5322
|
+
/** Through the HTTP action, so replies are rate limited per IP. */
|
|
5323
|
+
async createReply(reply) {
|
|
5324
|
+
let response;
|
|
5325
|
+
try {
|
|
5326
|
+
response = await fetch(`${this.config.convexSiteUrl}/widget/replies`, {
|
|
5327
|
+
method: "POST",
|
|
5328
|
+
headers: { "Content-Type": "application/json" },
|
|
5329
|
+
body: JSON.stringify({
|
|
5330
|
+
publicId: this.config.project,
|
|
5331
|
+
...reply
|
|
5332
|
+
})
|
|
5333
|
+
});
|
|
5334
|
+
} catch {
|
|
5335
|
+
throw new NuniApiError("Couldn't reach Nuni. Check your connection.", "network");
|
|
5336
|
+
}
|
|
5337
|
+
const data = await response.json().catch(() => ({}));
|
|
5338
|
+
if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
|
|
5339
|
+
return data.id;
|
|
5340
|
+
}
|
|
4643
5341
|
/** Attach a screenshot to the author's own, just-posted comment. */
|
|
4644
5342
|
async uploadScreenshot(id, authorSecret, image) {
|
|
4645
5343
|
const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
|
|
@@ -4689,6 +5387,34 @@ var NuniApi = class {
|
|
|
4689
5387
|
sessionToken
|
|
4690
5388
|
}));
|
|
4691
5389
|
}
|
|
5390
|
+
editReply(id, authorSecret, body) {
|
|
5391
|
+
return this.run(this.client.mutation(api.replies.editOwn, {
|
|
5392
|
+
id,
|
|
5393
|
+
authorSecret,
|
|
5394
|
+
body
|
|
5395
|
+
}));
|
|
5396
|
+
}
|
|
5397
|
+
deleteReply(id, authorSecret) {
|
|
5398
|
+
return this.run(this.client.mutation(api.replies.deleteOwn, {
|
|
5399
|
+
id,
|
|
5400
|
+
authorSecret
|
|
5401
|
+
}));
|
|
5402
|
+
}
|
|
5403
|
+
removeReply(id, sessionToken) {
|
|
5404
|
+
return this.run(this.client.mutation(api.replies.remove, {
|
|
5405
|
+
id,
|
|
5406
|
+
sessionToken
|
|
5407
|
+
}));
|
|
5408
|
+
}
|
|
5409
|
+
toggleReaction(commentId, targetId, emoji, authorSecret) {
|
|
5410
|
+
return this.run(this.client.mutation(api.reactions.toggle, {
|
|
5411
|
+
publicId: this.config.project,
|
|
5412
|
+
commentId,
|
|
5413
|
+
targetId,
|
|
5414
|
+
emoji,
|
|
5415
|
+
authorSecret
|
|
5416
|
+
}));
|
|
5417
|
+
}
|
|
4692
5418
|
remove(id, sessionToken) {
|
|
4693
5419
|
return this.run(this.client.mutation(api.comments.remove, {
|
|
4694
5420
|
id,
|
|
@@ -4723,17 +5449,6 @@ function icon(svg) {
|
|
|
4723
5449
|
span.append(document.importNode(parsed.documentElement, true));
|
|
4724
5450
|
return span;
|
|
4725
5451
|
}
|
|
4726
|
-
function timeAgo(ts, now = Date.now()) {
|
|
4727
|
-
const s = Math.max(0, Math.round((now - ts) / 1e3));
|
|
4728
|
-
if (s < 45) return "just now";
|
|
4729
|
-
const m = Math.round(s / 60);
|
|
4730
|
-
if (m < 60) return `${m}m ago`;
|
|
4731
|
-
const hr = Math.round(m / 60);
|
|
4732
|
-
if (hr < 24) return `${hr}h ago`;
|
|
4733
|
-
const d = Math.round(hr / 24);
|
|
4734
|
-
if (d < 30) return `${d}d ago`;
|
|
4735
|
-
return new Date(ts).toLocaleDateString();
|
|
4736
|
-
}
|
|
4737
5452
|
function initials(name) {
|
|
4738
5453
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
4739
5454
|
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
|
|
@@ -4742,7 +5457,7 @@ function initials(name) {
|
|
|
4742
5457
|
function colorFor(name) {
|
|
4743
5458
|
let hash = 0;
|
|
4744
5459
|
for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
|
|
4745
|
-
return `oklch(0.
|
|
5460
|
+
return `oklch(0.5 0.17 ${hash % 360})`;
|
|
4746
5461
|
}
|
|
4747
5462
|
/** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
|
|
4748
5463
|
async function copyText(text) {
|
|
@@ -4764,6 +5479,200 @@ async function copyText(text) {
|
|
|
4764
5479
|
area.remove();
|
|
4765
5480
|
}
|
|
4766
5481
|
}
|
|
5482
|
+
/** Any CSS color as RGB, using the browser's own parser. */
|
|
5483
|
+
function parseColor(color) {
|
|
5484
|
+
const probe = document.createElement("span");
|
|
5485
|
+
probe.style.color = color;
|
|
5486
|
+
if (!probe.style.color) return null;
|
|
5487
|
+
probe.style.display = "none";
|
|
5488
|
+
document.documentElement.appendChild(probe);
|
|
5489
|
+
const computed = getComputedStyle(probe).color;
|
|
5490
|
+
probe.remove();
|
|
5491
|
+
if (/^rgba?\(/.test(computed)) {
|
|
5492
|
+
const parts = computed.match(/[\d.]+/g)?.map(Number);
|
|
5493
|
+
if (parts && parts.length >= 3) return [
|
|
5494
|
+
parts[0],
|
|
5495
|
+
parts[1],
|
|
5496
|
+
parts[2]
|
|
5497
|
+
];
|
|
5498
|
+
}
|
|
5499
|
+
try {
|
|
5500
|
+
const canvas = document.createElement("canvas");
|
|
5501
|
+
canvas.width = canvas.height = 1;
|
|
5502
|
+
const ctx = canvas.getContext("2d");
|
|
5503
|
+
if (!ctx) return null;
|
|
5504
|
+
ctx.fillStyle = color;
|
|
5505
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
5506
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
5507
|
+
return [
|
|
5508
|
+
r,
|
|
5509
|
+
g,
|
|
5510
|
+
b
|
|
5511
|
+
];
|
|
5512
|
+
} catch {
|
|
5513
|
+
return null;
|
|
5514
|
+
}
|
|
5515
|
+
}
|
|
5516
|
+
/** Black or white text, whichever reads better on the color (WCAG). */
|
|
5517
|
+
function readableOn([r, g, b]) {
|
|
5518
|
+
const channel = (c) => {
|
|
5519
|
+
const v = c / 255;
|
|
5520
|
+
return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
5521
|
+
};
|
|
5522
|
+
const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
|
|
5523
|
+
return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
|
|
5524
|
+
}
|
|
5525
|
+
//#endregion
|
|
5526
|
+
//#region src/i18n.ts
|
|
5527
|
+
/**
|
|
5528
|
+
* Every word the widget shows. Override any of them with the `messages`
|
|
5529
|
+
* option; `locale` sets how times and counts are written.
|
|
5530
|
+
*
|
|
5531
|
+
* `{name}` is filled in. A message with forms (`one`, `other`, and `zero`,
|
|
5532
|
+
* `two`, `few`, `many` where a language has them) is picked by `{count}`.
|
|
5533
|
+
*/
|
|
5534
|
+
const EN = {
|
|
5535
|
+
toolbar: "Nuni comments",
|
|
5536
|
+
comment: "Comment",
|
|
5537
|
+
addComment: "Add a comment",
|
|
5538
|
+
addCommentKey: "Add a comment ({key})",
|
|
5539
|
+
cancelAdding: "Cancel adding a comment",
|
|
5540
|
+
pickElement: "Pick an element",
|
|
5541
|
+
allComments: "All comments",
|
|
5542
|
+
openCount: "Comments on this page: {count} open",
|
|
5543
|
+
commentSelection: "Comment on the selected text",
|
|
5544
|
+
commentSelectionKey: "Comment on the selected text ({key})",
|
|
5545
|
+
pickHint: "Click anything to comment, or drag to mark an area",
|
|
5546
|
+
pickHintMobile: "Tap anything to comment",
|
|
5547
|
+
pickHintKeys: "Arrow keys move, Enter comments, Esc cancels",
|
|
5548
|
+
picked: "Selected {element}",
|
|
5549
|
+
comments: "Comments",
|
|
5550
|
+
close: "Close",
|
|
5551
|
+
tabOpen: "Open ({count})",
|
|
5552
|
+
tabResolved: "Resolved ({count})",
|
|
5553
|
+
loading: "Loading…",
|
|
5554
|
+
noOpen: "No open comments on this page.",
|
|
5555
|
+
noOpenKey: "No open comments on this page. Press {key} to add one.",
|
|
5556
|
+
noResolved: "Nothing resolved yet.",
|
|
5557
|
+
notFoundHere: "Couldn't find on this page",
|
|
5558
|
+
otherPages: "Other pages",
|
|
5559
|
+
openOnPage: "{count} open",
|
|
5560
|
+
replyCount: {
|
|
5561
|
+
one: "{count} reply",
|
|
5562
|
+
other: "{count} replies"
|
|
5563
|
+
},
|
|
5564
|
+
edited: "edited",
|
|
5565
|
+
ownSite: "Own this website?",
|
|
5566
|
+
claim: "Claim Nuni",
|
|
5567
|
+
yourName: "Your name",
|
|
5568
|
+
leaveComment: "Leave a comment",
|
|
5569
|
+
notYou: "Not you?",
|
|
5570
|
+
screenshotAlt: "Screenshot that will be attached",
|
|
5571
|
+
screenshotNote: "Screenshot attached. Only the site owner sees it.",
|
|
5572
|
+
remove: "Remove",
|
|
5573
|
+
cancel: "Cancel",
|
|
5574
|
+
post: "Post",
|
|
5575
|
+
posting: "Posting…",
|
|
5576
|
+
save: "Save",
|
|
5577
|
+
send: "{key} + Enter",
|
|
5578
|
+
pinLabel: "Comment by {name}: {body}",
|
|
5579
|
+
commentBy: "Comment by {name}",
|
|
5580
|
+
openScreenshot: "Open the screenshot",
|
|
5581
|
+
screenshotTaken: "Screenshot taken when the comment was left",
|
|
5582
|
+
resolved: "Resolved",
|
|
5583
|
+
leftOn: "Left on {origin}",
|
|
5584
|
+
approximate: "Approximate",
|
|
5585
|
+
approximateHint: "The page changed; this pin is a best guess",
|
|
5586
|
+
notFound: "Element not found",
|
|
5587
|
+
originallyOn: "Originally on: {text}",
|
|
5588
|
+
resolve: "Resolve",
|
|
5589
|
+
reopen: "Reopen",
|
|
5590
|
+
copyForAgent: "Copy for agent",
|
|
5591
|
+
copyForAgentHint: "Copy for your coding agent",
|
|
5592
|
+
loadingContext: "Loading page context…",
|
|
5593
|
+
edit: "Edit",
|
|
5594
|
+
delete: "Delete",
|
|
5595
|
+
owner: "Owner",
|
|
5596
|
+
addReaction: "Add a reaction",
|
|
5597
|
+
reactions: "Reactions",
|
|
5598
|
+
reactWith: "React with {emoji}",
|
|
5599
|
+
reply: "Reply",
|
|
5600
|
+
replyFirst: "Reply to start a thread",
|
|
5601
|
+
sendReply: "Send reply",
|
|
5602
|
+
editReply: "Edit reply",
|
|
5603
|
+
deleteReply: "Delete reply",
|
|
5604
|
+
confirmDeleteComment: "Delete this comment? This can't be undone.",
|
|
5605
|
+
confirmDeleteReply: "Delete this reply?",
|
|
5606
|
+
added: "Comment added",
|
|
5607
|
+
saved: "Saved",
|
|
5608
|
+
deleted: "Deleted",
|
|
5609
|
+
replyDeleted: "Reply deleted",
|
|
5610
|
+
reopened: "Reopened",
|
|
5611
|
+
copied: "Copied for your coding agent",
|
|
5612
|
+
copyFailed: "Couldn't copy",
|
|
5613
|
+
signedIn: "You're signed in as the owner",
|
|
5614
|
+
linkGone: "That comment was deleted or is on another page",
|
|
5615
|
+
enterName: "Enter your name",
|
|
5616
|
+
writeFirst: "Write a comment first",
|
|
5617
|
+
commentTooLong: "Comments are limited to {count} characters",
|
|
5618
|
+
replyTooLong: "Replies are limited to {count} characters",
|
|
5619
|
+
postFailed: "Couldn't post the comment",
|
|
5620
|
+
replyFailed: "Couldn't post the reply",
|
|
5621
|
+
reactFailed: "Couldn't react",
|
|
5622
|
+
failed: "Something went wrong",
|
|
5623
|
+
justNow: "just now"
|
|
5624
|
+
};
|
|
5625
|
+
function isApple() {
|
|
5626
|
+
if (typeof navigator === "undefined") return false;
|
|
5627
|
+
const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
|
|
5628
|
+
return /mac|iphone|ipad|ipod/i.test(platform || navigator.userAgent);
|
|
5629
|
+
}
|
|
5630
|
+
function createI18n(locale, overrides = {}) {
|
|
5631
|
+
const messages = {
|
|
5632
|
+
...EN,
|
|
5633
|
+
...overrides
|
|
5634
|
+
};
|
|
5635
|
+
let plural = null;
|
|
5636
|
+
let relative = null;
|
|
5637
|
+
try {
|
|
5638
|
+
plural = new Intl.PluralRules(locale);
|
|
5639
|
+
relative = new Intl.RelativeTimeFormat(locale, {
|
|
5640
|
+
numeric: "auto",
|
|
5641
|
+
style: "narrow"
|
|
5642
|
+
});
|
|
5643
|
+
} catch {
|
|
5644
|
+
plural = new Intl.PluralRules();
|
|
5645
|
+
relative = new Intl.RelativeTimeFormat(void 0, {
|
|
5646
|
+
numeric: "auto",
|
|
5647
|
+
style: "narrow"
|
|
5648
|
+
});
|
|
5649
|
+
}
|
|
5650
|
+
const t = (key, vars = {}) => {
|
|
5651
|
+
const message = messages[key] ?? EN[key];
|
|
5652
|
+
return (typeof message === "string" ? message : message[plural.select(Number(vars.count ?? 0))] ?? message.other).replace(/\{(\w+)\}/g, (match, name) => name in vars ? String(vars[name]) : match);
|
|
5653
|
+
};
|
|
5654
|
+
const timeAgo = (ts, now = Date.now()) => {
|
|
5655
|
+
const s = Math.max(0, Math.round((now - ts) / 1e3));
|
|
5656
|
+
if (s < 45) return t("justNow");
|
|
5657
|
+
const m = Math.round(s / 60);
|
|
5658
|
+
if (m < 60) return relative.format(-m, "minute");
|
|
5659
|
+
const hr = Math.round(m / 60);
|
|
5660
|
+
if (hr < 24) return relative.format(-hr, "hour");
|
|
5661
|
+
const d = Math.round(hr / 24);
|
|
5662
|
+
if (d < 30) return relative.format(-d, "day");
|
|
5663
|
+
try {
|
|
5664
|
+
return new Date(ts).toLocaleDateString(locale);
|
|
5665
|
+
} catch {
|
|
5666
|
+
return new Date(ts).toLocaleDateString();
|
|
5667
|
+
}
|
|
5668
|
+
};
|
|
5669
|
+
return {
|
|
5670
|
+
locale,
|
|
5671
|
+
t,
|
|
5672
|
+
timeAgo,
|
|
5673
|
+
mod: isApple() ? "⌘" : "Ctrl"
|
|
5674
|
+
};
|
|
5675
|
+
}
|
|
4767
5676
|
//#endregion
|
|
4768
5677
|
//#region src/icons.ts
|
|
4769
5678
|
const s = (d) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
|
@@ -4777,6 +5686,7 @@ const ICONS = {
|
|
|
4777
5686
|
edit: s("<path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/>"),
|
|
4778
5687
|
send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
|
|
4779
5688
|
bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
|
|
5689
|
+
smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
|
|
4780
5690
|
target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
|
|
4781
5691
|
github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
|
|
4782
5692
|
link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
|
|
@@ -4959,6 +5869,57 @@ function sha256(data) {
|
|
|
4959
5869
|
return out;
|
|
4960
5870
|
}
|
|
4961
5871
|
//#endregion
|
|
5872
|
+
//#region src/scroll-to-pin.ts
|
|
5873
|
+
/** Reveal the pin itself, including scroll containers and same-origin frames. */
|
|
5874
|
+
function scrollToPin(element, point) {
|
|
5875
|
+
element.scrollIntoView({
|
|
5876
|
+
block: "center",
|
|
5877
|
+
inline: "center",
|
|
5878
|
+
behavior: "instant"
|
|
5879
|
+
});
|
|
5880
|
+
let node = element;
|
|
5881
|
+
while (node) {
|
|
5882
|
+
const parent = node.parentElement;
|
|
5883
|
+
if (parent) {
|
|
5884
|
+
const style = parent.ownerDocument.defaultView?.getComputedStyle(parent);
|
|
5885
|
+
const scrollX = style && /auto|scroll|hidden/.test(style.overflowX);
|
|
5886
|
+
const scrollY = style && /auto|scroll|hidden/.test(style.overflowY);
|
|
5887
|
+
if (parent !== parent.ownerDocument.scrollingElement && (scrollX || scrollY)) {
|
|
5888
|
+
const rect = viewportRect(parent, document);
|
|
5889
|
+
const pin = point();
|
|
5890
|
+
parent.scrollBy({
|
|
5891
|
+
left: scrollX ? pin.x - rect.left - parent.clientLeft - parent.clientWidth / 2 : 0,
|
|
5892
|
+
top: scrollY ? pin.y - rect.top - parent.clientTop - parent.clientHeight / 2 : 0,
|
|
5893
|
+
behavior: "instant"
|
|
5894
|
+
});
|
|
5895
|
+
}
|
|
5896
|
+
node = parent;
|
|
5897
|
+
continue;
|
|
5898
|
+
}
|
|
5899
|
+
const root = node.getRootNode();
|
|
5900
|
+
if (isShadowRoot(root)) {
|
|
5901
|
+
node = root.host;
|
|
5902
|
+
continue;
|
|
5903
|
+
}
|
|
5904
|
+
const doc = node.ownerDocument;
|
|
5905
|
+
const view = doc.defaultView;
|
|
5906
|
+
const frame = frameElementOf(doc);
|
|
5907
|
+
if (view) {
|
|
5908
|
+
const pin = point();
|
|
5909
|
+
const rect = frame ? viewportRect(frame, document) : {
|
|
5910
|
+
left: 0,
|
|
5911
|
+
top: 0
|
|
5912
|
+
};
|
|
5913
|
+
view.scrollBy({
|
|
5914
|
+
left: pin.x - rect.left - (frame?.clientLeft ?? 0) - view.innerWidth / 2,
|
|
5915
|
+
top: pin.y - rect.top - (frame?.clientTop ?? 0) - view.innerHeight / 2,
|
|
5916
|
+
behavior: "instant"
|
|
5917
|
+
});
|
|
5918
|
+
}
|
|
5919
|
+
node = frame;
|
|
5920
|
+
}
|
|
5921
|
+
}
|
|
5922
|
+
//#endregion
|
|
4962
5923
|
//#region src/storage.ts
|
|
4963
5924
|
/** localStorage that never throws (private mode, blocked storage, sandboxed iframes). */
|
|
4964
5925
|
const memory = /* @__PURE__ */ new Map();
|
|
@@ -5013,7 +5974,7 @@ const STYLES = `
|
|
|
5013
5974
|
-moz-osx-font-smoothing: grayscale;
|
|
5014
5975
|
}
|
|
5015
5976
|
@media (prefers-color-scheme: dark) {
|
|
5016
|
-
:host {
|
|
5977
|
+
:host(:not([data-theme="light"])) {
|
|
5017
5978
|
--n-bg: #1d1b20;
|
|
5018
5979
|
--n-bg-subtle: #2a272e;
|
|
5019
5980
|
--n-fg: #f4f2f6;
|
|
@@ -5024,6 +5985,18 @@ const STYLES = `
|
|
|
5024
5985
|
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
5025
5986
|
}
|
|
5026
5987
|
}
|
|
5988
|
+
:host([data-theme="dark"]) {
|
|
5989
|
+
--n-bg: #1d1b20;
|
|
5990
|
+
--n-bg-subtle: #2a272e;
|
|
5991
|
+
--n-fg: #f4f2f6;
|
|
5992
|
+
--n-fg-muted: #a8a2ae;
|
|
5993
|
+
--n-border: rgba(255, 255, 255, 0.12);
|
|
5994
|
+
--n-accent: #ff4d94;
|
|
5995
|
+
--n-accent-soft: rgba(255, 77, 148, 0.18);
|
|
5996
|
+
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
5997
|
+
}
|
|
5998
|
+
:host([data-theme="light"]) { color-scheme: light; }
|
|
5999
|
+
:host([data-theme="dark"]) { color-scheme: dark; }
|
|
5027
6000
|
*, *::before, *::after { box-sizing: border-box; }
|
|
5028
6001
|
button, input, textarea { font: inherit; color: inherit; }
|
|
5029
6002
|
button { cursor: pointer; }
|
|
@@ -5077,6 +6050,10 @@ button { cursor: pointer; }
|
|
|
5077
6050
|
|
|
5078
6051
|
/* Picking */
|
|
5079
6052
|
.pick-hint {
|
|
6053
|
+
display: flex;
|
|
6054
|
+
gap: 8px;
|
|
6055
|
+
align-items: center;
|
|
6056
|
+
max-width: calc(100vw - 24px);
|
|
5080
6057
|
position: fixed;
|
|
5081
6058
|
top: 12px;
|
|
5082
6059
|
left: 50%;
|
|
@@ -5113,6 +6090,94 @@ button { cursor: pointer; }
|
|
|
5113
6090
|
text-overflow: ellipsis;
|
|
5114
6091
|
}
|
|
5115
6092
|
|
|
6093
|
+
.drag-box { transition: none; border-style: dashed; }
|
|
6094
|
+
|
|
6095
|
+
/* Text and area comments */
|
|
6096
|
+
.marks { position: fixed; inset: 0; pointer-events: none; }
|
|
6097
|
+
.mark {
|
|
6098
|
+
position: fixed;
|
|
6099
|
+
border-bottom: 2px solid var(--n-accent);
|
|
6100
|
+
background: var(--n-accent-soft);
|
|
6101
|
+
opacity: 0.7;
|
|
6102
|
+
pointer-events: none;
|
|
6103
|
+
}
|
|
6104
|
+
.mark-active { opacity: 1; background: color-mix(in srgb, var(--n-accent) 26%, transparent); }
|
|
6105
|
+
.area {
|
|
6106
|
+
position: fixed;
|
|
6107
|
+
border: 2px dashed var(--n-accent);
|
|
6108
|
+
border-radius: 4px;
|
|
6109
|
+
background: var(--n-accent-soft);
|
|
6110
|
+
pointer-events: none;
|
|
6111
|
+
}
|
|
6112
|
+
.select-btn {
|
|
6113
|
+
position: fixed;
|
|
6114
|
+
left: 0;
|
|
6115
|
+
top: 0;
|
|
6116
|
+
display: inline-flex;
|
|
6117
|
+
align-items: center;
|
|
6118
|
+
gap: 6px;
|
|
6119
|
+
height: 32px;
|
|
6120
|
+
padding: 0 12px;
|
|
6121
|
+
border: 0;
|
|
6122
|
+
border-radius: 999px;
|
|
6123
|
+
background: var(--n-fg);
|
|
6124
|
+
color: var(--n-bg);
|
|
6125
|
+
font-weight: 550;
|
|
6126
|
+
font-size: 13px;
|
|
6127
|
+
box-shadow: var(--n-shadow);
|
|
6128
|
+
pointer-events: auto;
|
|
6129
|
+
}
|
|
6130
|
+
.select-btn[hidden] { display: none; }
|
|
6131
|
+
.quote {
|
|
6132
|
+
margin: 0;
|
|
6133
|
+
padding: 2px 0 2px 10px;
|
|
6134
|
+
border-left: 3px solid var(--n-accent);
|
|
6135
|
+
color: var(--n-fg-muted);
|
|
6136
|
+
font-style: italic;
|
|
6137
|
+
white-space: pre-wrap;
|
|
6138
|
+
overflow-wrap: anywhere;
|
|
6139
|
+
max-height: 4.5em;
|
|
6140
|
+
overflow: hidden;
|
|
6141
|
+
}
|
|
6142
|
+
.item-quote {
|
|
6143
|
+
margin-top: 4px;
|
|
6144
|
+
padding-left: 8px;
|
|
6145
|
+
border-left: 2px solid var(--n-accent);
|
|
6146
|
+
color: var(--n-fg-muted);
|
|
6147
|
+
font-size: 12px;
|
|
6148
|
+
font-style: italic;
|
|
6149
|
+
white-space: nowrap;
|
|
6150
|
+
overflow: hidden;
|
|
6151
|
+
text-overflow: ellipsis;
|
|
6152
|
+
}
|
|
6153
|
+
|
|
6154
|
+
.pick-keys { opacity: 0.7; }
|
|
6155
|
+
.pick-keys::before { content: "·"; margin-right: 8px; }
|
|
6156
|
+
@media (max-width: 900px) { .pick-keys { display: none; } }
|
|
6157
|
+
|
|
6158
|
+
/* Confirm dialog */
|
|
6159
|
+
.confirm-backdrop {
|
|
6160
|
+
position: fixed;
|
|
6161
|
+
inset: 0;
|
|
6162
|
+
display: grid;
|
|
6163
|
+
place-items: center;
|
|
6164
|
+
background: rgba(0, 0, 0, 0.32);
|
|
6165
|
+
pointer-events: auto;
|
|
6166
|
+
}
|
|
6167
|
+
.confirm {
|
|
6168
|
+
width: min(360px, calc(100vw - 32px));
|
|
6169
|
+
padding: 16px;
|
|
6170
|
+
border-radius: var(--n-radius);
|
|
6171
|
+
background: var(--n-bg);
|
|
6172
|
+
color: var(--n-fg);
|
|
6173
|
+
border: 1px solid var(--n-border);
|
|
6174
|
+
box-shadow: var(--n-shadow);
|
|
6175
|
+
display: grid;
|
|
6176
|
+
gap: 12px;
|
|
6177
|
+
}
|
|
6178
|
+
.confirm p { margin: 0; }
|
|
6179
|
+
.btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
|
|
6180
|
+
|
|
5116
6181
|
/* Pins */
|
|
5117
6182
|
.pin {
|
|
5118
6183
|
position: fixed;
|
|
@@ -5138,6 +6203,22 @@ button { cursor: pointer; }
|
|
|
5138
6203
|
.pin:hover, .pin[data-active="true"] { z-index: 2; }
|
|
5139
6204
|
.pin[data-active="true"] { box-shadow: 0 0 0 3px var(--n-accent), 0 2px 8px rgba(0,0,0,0.25); }
|
|
5140
6205
|
.pin[data-status="resolved"] { opacity: 0.55; filter: grayscale(0.6); }
|
|
6206
|
+
.pin[data-replies]::after {
|
|
6207
|
+
content: attr(data-replies);
|
|
6208
|
+
position: absolute;
|
|
6209
|
+
top: -6px;
|
|
6210
|
+
right: -8px;
|
|
6211
|
+
min-width: 16px;
|
|
6212
|
+
height: 16px;
|
|
6213
|
+
padding: 0 4px;
|
|
6214
|
+
border-radius: 999px;
|
|
6215
|
+
background: var(--n-bg);
|
|
6216
|
+
color: var(--n-fg);
|
|
6217
|
+
border: 1px solid var(--n-border);
|
|
6218
|
+
font-size: 10px;
|
|
6219
|
+
line-height: 14px;
|
|
6220
|
+
text-align: center;
|
|
6221
|
+
}
|
|
5141
6222
|
.pin[data-confidence="low"] { border-style: dashed; }
|
|
5142
6223
|
.pin[hidden] { display: none; }
|
|
5143
6224
|
.pin-pending { opacity: 0.7; }
|
|
@@ -5156,6 +6237,43 @@ button { cursor: pointer; }
|
|
|
5156
6237
|
overflow: hidden;
|
|
5157
6238
|
}
|
|
5158
6239
|
.card-body { padding: 12px; display: grid; gap: 10px; }
|
|
6240
|
+
.card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
|
|
6241
|
+
.thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
|
|
6242
|
+
.replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
|
|
6243
|
+
.reply { display: grid; gap: 6px; }
|
|
6244
|
+
.avatar-sm { width: 20px; height: 20px; font-size: 9px; }
|
|
6245
|
+
.badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
|
|
6246
|
+
.reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
|
|
6247
|
+
.reply-form .field { flex: 1 1 100%; }
|
|
6248
|
+
/* A fixed size: growing on focus would move the Send button mid-click. */
|
|
6249
|
+
.reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
|
|
6250
|
+
.reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
|
|
6251
|
+
.reaction {
|
|
6252
|
+
display: inline-flex;
|
|
6253
|
+
align-items: center;
|
|
6254
|
+
gap: 4px;
|
|
6255
|
+
height: 24px;
|
|
6256
|
+
padding: 0 8px;
|
|
6257
|
+
border: 1px solid var(--n-border);
|
|
6258
|
+
border-radius: 999px;
|
|
6259
|
+
background: var(--n-bg);
|
|
6260
|
+
color: var(--n-fg);
|
|
6261
|
+
font-size: 12px;
|
|
6262
|
+
}
|
|
6263
|
+
.reaction:hover { background: var(--n-bg-subtle); }
|
|
6264
|
+
.reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
|
|
6265
|
+
.reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
|
|
6266
|
+
.reaction-picker {
|
|
6267
|
+
display: flex;
|
|
6268
|
+
gap: 2px;
|
|
6269
|
+
padding: 4px;
|
|
6270
|
+
border: 1px solid var(--n-border);
|
|
6271
|
+
border-radius: 999px;
|
|
6272
|
+
background: var(--n-bg);
|
|
6273
|
+
box-shadow: var(--n-shadow);
|
|
6274
|
+
}
|
|
6275
|
+
.reaction-picker .reaction { border-color: transparent; font-size: 14px; }
|
|
6276
|
+
.btn-xs { width: 24px; height: 24px; }
|
|
5159
6277
|
.card-head { display: flex; align-items: center; gap: 8px; }
|
|
5160
6278
|
.card-head .spacer { flex: 1; }
|
|
5161
6279
|
.avatar {
|
|
@@ -5330,14 +6448,66 @@ a.item { color: inherit; text-decoration: none; }
|
|
|
5330
6448
|
@media (prefers-reduced-motion: reduce) {
|
|
5331
6449
|
* { transition: none !important; animation: none !important; }
|
|
5332
6450
|
}
|
|
6451
|
+
|
|
6452
|
+
/* The toolbar's corner (the position option) */
|
|
6453
|
+
:host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
|
|
6454
|
+
:host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
|
|
6455
|
+
:host([data-position$="left"]) .panel { right: auto; left: 12px; }
|
|
6456
|
+
:host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
|
|
6457
|
+
:host([data-position^="top"]) .toast { bottom: auto; top: 72px; }
|
|
6458
|
+
@media (max-width: 640px) {
|
|
6459
|
+
:host([data-position$="left"]) .toolbar { left: 12px; }
|
|
6460
|
+
:host([data-position^="top"]) .toolbar { top: 12px; }
|
|
6461
|
+
}
|
|
5333
6462
|
`;
|
|
5334
6463
|
//#endregion
|
|
5335
6464
|
//#region src/widget.ts
|
|
5336
6465
|
const PICKING_CLASS = "nuni-picking";
|
|
6466
|
+
const PICK_EVENTS = [
|
|
6467
|
+
"pointerdown",
|
|
6468
|
+
"mousedown",
|
|
6469
|
+
"pointerup",
|
|
6470
|
+
"mouseup"
|
|
6471
|
+
];
|
|
6472
|
+
/** How far the pointer moves before a press becomes an area drag. */
|
|
6473
|
+
const DRAG_THRESHOLD = 6;
|
|
6474
|
+
/** The rectangles of a range that have a size (lines of text). */
|
|
6475
|
+
function lineRects(range) {
|
|
6476
|
+
return Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
6477
|
+
}
|
|
6478
|
+
/** An area comment's box on screen, from its element's box. */
|
|
6479
|
+
function regionBox(anchor, el) {
|
|
6480
|
+
const region = anchor.region;
|
|
6481
|
+
if (!region) return null;
|
|
6482
|
+
const r = viewportRect(el, document);
|
|
6483
|
+
return {
|
|
6484
|
+
left: r.left + region.x * r.width,
|
|
6485
|
+
top: r.top + region.y * r.height,
|
|
6486
|
+
width: region.w * r.width,
|
|
6487
|
+
height: region.h * r.height
|
|
6488
|
+
};
|
|
6489
|
+
}
|
|
6490
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
6491
|
+
/** Run when the browser is idle, or soon. */
|
|
6492
|
+
function whenIdle(fn) {
|
|
6493
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(fn, { timeout: 100 });
|
|
6494
|
+
else setTimeout(fn, 0);
|
|
6495
|
+
}
|
|
6496
|
+
/** Same-origin iframe documents on the page, nested ones too. */
|
|
6497
|
+
function frameDocuments(doc, depth = 0) {
|
|
6498
|
+
if (depth > 3) return [];
|
|
6499
|
+
const out = [];
|
|
6500
|
+
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
|
6501
|
+
const inner = frameDocument(frame);
|
|
6502
|
+
if (inner?.documentElement) out.push(inner, ...frameDocuments(inner, depth + 1));
|
|
6503
|
+
}
|
|
6504
|
+
return out;
|
|
6505
|
+
}
|
|
5337
6506
|
const MOBILE_QUERY = "(max-width: 640px)";
|
|
5338
6507
|
function isTypingTarget(target) {
|
|
5339
|
-
if (!
|
|
5340
|
-
|
|
6508
|
+
if (!target || target.nodeType !== 1) return false;
|
|
6509
|
+
const el = target;
|
|
6510
|
+
return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
|
5341
6511
|
}
|
|
5342
6512
|
function dataUrl(blob) {
|
|
5343
6513
|
return new Promise((resolve, reject) => {
|
|
@@ -5354,14 +6524,31 @@ function describeElement(el) {
|
|
|
5354
6524
|
const text = elementText(el, 40);
|
|
5355
6525
|
return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
|
|
5356
6526
|
}
|
|
6527
|
+
/**
|
|
6528
|
+
* Build an "Other pages" link target, or null when it would leave the
|
|
6529
|
+
* current origin. The stored path comes from anonymous comment submissions;
|
|
6530
|
+
* one that does not start with "/" (".evil.com/x", "@evil.com/") would
|
|
6531
|
+
* extend or replace the host once the concatenated string is parsed as a
|
|
6532
|
+
* URL, sending the visitor - and the page's query string - to another site.
|
|
6533
|
+
*/
|
|
6534
|
+
function otherPageHref(path, loc) {
|
|
6535
|
+
const candidate = /[?#]/.test(path) ? loc.origin + path : loc.origin + path + loc.search;
|
|
6536
|
+
try {
|
|
6537
|
+
return new URL(candidate).origin === loc.origin ? candidate : null;
|
|
6538
|
+
} catch {
|
|
6539
|
+
return null;
|
|
6540
|
+
}
|
|
6541
|
+
}
|
|
5357
6542
|
var NuniWidget = class {
|
|
5358
6543
|
constructor(config, runtime) {
|
|
5359
6544
|
this.config = config;
|
|
5360
6545
|
this.runtime = runtime;
|
|
5361
6546
|
_defineProperty(this, "host", void 0);
|
|
5362
6547
|
_defineProperty(this, "root", void 0);
|
|
6548
|
+
_defineProperty(this, "markLayer", void 0);
|
|
5363
6549
|
_defineProperty(this, "pinLayer", void 0);
|
|
5364
6550
|
_defineProperty(this, "overlayLayer", void 0);
|
|
6551
|
+
_defineProperty(this, "selectButton", void 0);
|
|
5365
6552
|
_defineProperty(this, "uiLayer", void 0);
|
|
5366
6553
|
_defineProperty(this, "api", void 0);
|
|
5367
6554
|
_defineProperty(this, "cleanups", []);
|
|
@@ -5393,10 +6580,28 @@ var NuniWidget = class {
|
|
|
5393
6580
|
_defineProperty(this, "ownerDetailId", null);
|
|
5394
6581
|
_defineProperty(this, "ownerDetailLoaded", false);
|
|
5395
6582
|
_defineProperty(this, "ownerDetailUnsub", null);
|
|
6583
|
+
_defineProperty(this, "thread", null);
|
|
6584
|
+
_defineProperty(this, "threadId", null);
|
|
6585
|
+
_defineProperty(this, "threadUnsub", null);
|
|
6586
|
+
_defineProperty(this, "resolveRun", 0);
|
|
6587
|
+
_defineProperty(this, "scopeWatchers", /* @__PURE__ */ new Map());
|
|
6588
|
+
_defineProperty(this, "pickDocs", []);
|
|
6589
|
+
_defineProperty(this, "dragStart", null);
|
|
6590
|
+
_defineProperty(this, "dragBox", null);
|
|
6591
|
+
_defineProperty(this, "dragEnded", false);
|
|
6592
|
+
_defineProperty(this, "selection", null);
|
|
6593
|
+
_defineProperty(this, "pointerDown", false);
|
|
6594
|
+
_defineProperty(this, "selectionFrame", 0);
|
|
6595
|
+
_defineProperty(this, "returnFocus", null);
|
|
6596
|
+
_defineProperty(this, "focusNext", null);
|
|
6597
|
+
_defineProperty(this, "confirming", null);
|
|
6598
|
+
_defineProperty(this, "live", void 0);
|
|
5396
6599
|
_defineProperty(this, "layoutFrame", 0);
|
|
5397
6600
|
_defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
|
|
5398
6601
|
_defineProperty(this, "resolveTimer", 0);
|
|
6602
|
+
_defineProperty(this, "i18n", void 0);
|
|
5399
6603
|
_defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
|
|
6604
|
+
_defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
|
|
5400
6605
|
_defineProperty(this, "swallow", (e) => {
|
|
5401
6606
|
if (!this.fromPage(e)) return;
|
|
5402
6607
|
if (e.type === "pointerdown" && e instanceof PointerEvent) {
|
|
@@ -5405,11 +6610,41 @@ var NuniWidget = class {
|
|
|
5405
6610
|
this.hoverEl = pickTarget(target);
|
|
5406
6611
|
this.renderHighlight();
|
|
5407
6612
|
}
|
|
6613
|
+
this.dragEnded = false;
|
|
6614
|
+
this.dragStart = e.pointerType !== "touch" && e.button === 0 && (target?.ownerDocument ?? null) === document ? {
|
|
6615
|
+
x: e.clientX,
|
|
6616
|
+
y: e.clientY
|
|
6617
|
+
} : null;
|
|
5408
6618
|
}
|
|
6619
|
+
if (e.type === "pointerup" && this.dragBox) {
|
|
6620
|
+
const box = this.dragBox;
|
|
6621
|
+
this.dragStart = null;
|
|
6622
|
+
this.dragBox = null;
|
|
6623
|
+
this.dragEnded = true;
|
|
6624
|
+
setTimeout(() => {
|
|
6625
|
+
this.dragEnded = false;
|
|
6626
|
+
if (this.picking) this.commentOnArea(box);
|
|
6627
|
+
}, 0);
|
|
6628
|
+
} else if (e.type === "pointerup") this.dragStart = null;
|
|
5409
6629
|
e.preventDefault();
|
|
5410
6630
|
e.stopPropagation();
|
|
5411
6631
|
});
|
|
5412
6632
|
_defineProperty(this, "onPickMove", (e) => {
|
|
6633
|
+
const start = this.dragStart;
|
|
6634
|
+
if (start) {
|
|
6635
|
+
const dx = e.clientX - start.x;
|
|
6636
|
+
const dy = e.clientY - start.y;
|
|
6637
|
+
if (this.dragBox || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
6638
|
+
this.dragBox = {
|
|
6639
|
+
left: Math.min(start.x, e.clientX),
|
|
6640
|
+
top: Math.min(start.y, e.clientY),
|
|
6641
|
+
width: Math.abs(dx),
|
|
6642
|
+
height: Math.abs(dy)
|
|
6643
|
+
};
|
|
6644
|
+
this.renderHighlight();
|
|
6645
|
+
return;
|
|
6646
|
+
}
|
|
6647
|
+
}
|
|
5413
6648
|
const target = this.fromPage(e);
|
|
5414
6649
|
const next = target ? pickTarget(target) : null;
|
|
5415
6650
|
if (next === this.hoverEl) return;
|
|
@@ -5422,27 +6657,21 @@ var NuniWidget = class {
|
|
|
5422
6657
|
e.preventDefault();
|
|
5423
6658
|
e.stopPropagation();
|
|
5424
6659
|
e.stopImmediatePropagation();
|
|
6660
|
+
if (this.dragEnded) return;
|
|
5425
6661
|
const element = pickTarget(target);
|
|
5426
6662
|
const anchor = captureAnchor(element, {
|
|
5427
6663
|
x: e.clientX,
|
|
5428
6664
|
y: e.clientY
|
|
5429
|
-
}, {
|
|
5430
|
-
|
|
5431
|
-
|
|
6665
|
+
}, {
|
|
6666
|
+
ignoreAttributePrefixes: [],
|
|
6667
|
+
document
|
|
6668
|
+
});
|
|
6669
|
+
this.openComposer({
|
|
5432
6670
|
anchor,
|
|
5433
6671
|
element
|
|
5434
|
-
};
|
|
5435
|
-
if (this.config.capture.dom) try {
|
|
5436
|
-
draft.dom = domContext(element);
|
|
5437
|
-
} catch {}
|
|
5438
|
-
this.card = {
|
|
5439
|
-
kind: "composer",
|
|
5440
|
-
draft
|
|
5441
|
-
};
|
|
5442
|
-
this.render();
|
|
5443
|
-
this.focusComposer();
|
|
5444
|
-
this.takeScreenshot(draft);
|
|
6672
|
+
});
|
|
5445
6673
|
});
|
|
6674
|
+
this.i18n = createI18n(config.locale, config.messages);
|
|
5446
6675
|
let secret = read(KEYS.secret);
|
|
5447
6676
|
if (!secret) {
|
|
5448
6677
|
secret = generateSecret();
|
|
@@ -5453,15 +6682,56 @@ var NuniWidget = class {
|
|
|
5453
6682
|
this.host = document.createElement("div");
|
|
5454
6683
|
this.host.id = "nuni-root";
|
|
5455
6684
|
this.host.setAttribute("data-nuni", "");
|
|
6685
|
+
this.applyAppearance();
|
|
5456
6686
|
this.root = this.host.attachShadow({ mode: "open" });
|
|
5457
6687
|
const style = document.createElement("style");
|
|
5458
6688
|
style.textContent = STYLES;
|
|
6689
|
+
this.markLayer = h("div", {
|
|
6690
|
+
class: "marks",
|
|
6691
|
+
"aria-hidden": "true"
|
|
6692
|
+
});
|
|
5459
6693
|
this.pinLayer = h("div", { class: "pins" });
|
|
5460
6694
|
this.overlayLayer = h("div", { class: "overlay" });
|
|
5461
6695
|
this.uiLayer = h("div", { class: "ui" });
|
|
5462
|
-
this.
|
|
6696
|
+
this.selectButton = h("button", {
|
|
6697
|
+
class: "select-btn",
|
|
6698
|
+
type: "button",
|
|
6699
|
+
hidden: true,
|
|
6700
|
+
title: this.withKey("commentSelection", "commentSelectionKey"),
|
|
6701
|
+
onmousedown: (e) => e.preventDefault(),
|
|
6702
|
+
onpointerdown: (e) => e.preventDefault(),
|
|
6703
|
+
onclick: () => this.commentOnSelection()
|
|
6704
|
+
}, icon(ICONS.comment), this.t("comment"));
|
|
6705
|
+
this.live = h("div", {
|
|
6706
|
+
class: "sr",
|
|
6707
|
+
role: "status",
|
|
6708
|
+
"aria-live": "polite"
|
|
6709
|
+
});
|
|
6710
|
+
this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
|
|
6711
|
+
this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
|
|
5463
6712
|
this.api = new NuniApi(config);
|
|
5464
6713
|
}
|
|
6714
|
+
t(key, vars) {
|
|
6715
|
+
return this.i18n.t(key, vars);
|
|
6716
|
+
}
|
|
6717
|
+
/** A message that names the hotkey, or the plain one when it is off. */
|
|
6718
|
+
withKey(plain, keyed) {
|
|
6719
|
+
const key = this.config.hotkey;
|
|
6720
|
+
return key ? this.t(keyed, { key: key.toUpperCase() }) : this.t(plain);
|
|
6721
|
+
}
|
|
6722
|
+
/** Position, theme, brand color and stacking order from the options. */
|
|
6723
|
+
applyAppearance() {
|
|
6724
|
+
const { position, theme, accentColor, zIndex } = this.config;
|
|
6725
|
+
this.host.dataset.position = position;
|
|
6726
|
+
this.host.dataset.theme = theme;
|
|
6727
|
+
if (zIndex !== null) this.host.style.zIndex = String(zIndex);
|
|
6728
|
+
const rgb = accentColor ? parseColor(accentColor) : null;
|
|
6729
|
+
if (!rgb) return;
|
|
6730
|
+
const [r, g, b] = rgb;
|
|
6731
|
+
this.host.style.setProperty("--n-accent", `rgb(${r}, ${g}, ${b})`);
|
|
6732
|
+
this.host.style.setProperty("--n-accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`);
|
|
6733
|
+
this.host.style.setProperty("--n-accent-fg", readableOn(rgb));
|
|
6734
|
+
}
|
|
5465
6735
|
start() {
|
|
5466
6736
|
document.body.appendChild(this.host);
|
|
5467
6737
|
sha256Hex(this.secret).then((hash) => {
|
|
@@ -5494,6 +6764,25 @@ var NuniWidget = class {
|
|
|
5494
6764
|
});
|
|
5495
6765
|
window.addEventListener("resize", onScroll, { passive: true });
|
|
5496
6766
|
window.addEventListener("message", onMessage);
|
|
6767
|
+
const onSelection = () => this.scheduleSelection();
|
|
6768
|
+
const onDown = (e) => {
|
|
6769
|
+
if (e.composedPath().includes(this.host)) return;
|
|
6770
|
+
this.pointerDown = true;
|
|
6771
|
+
this.scheduleSelection();
|
|
6772
|
+
};
|
|
6773
|
+
const onUp = () => {
|
|
6774
|
+
this.pointerDown = false;
|
|
6775
|
+
this.scheduleSelection();
|
|
6776
|
+
};
|
|
6777
|
+
document.addEventListener("selectionchange", onSelection);
|
|
6778
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
6779
|
+
document.addEventListener("pointerup", onUp, true);
|
|
6780
|
+
this.cleanups.push(() => {
|
|
6781
|
+
document.removeEventListener("selectionchange", onSelection);
|
|
6782
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
6783
|
+
document.removeEventListener("pointerup", onUp, true);
|
|
6784
|
+
cancelAnimationFrame(this.selectionFrame);
|
|
6785
|
+
});
|
|
5497
6786
|
this.cleanups.push(() => {
|
|
5498
6787
|
window.removeEventListener("keydown", onKey, true);
|
|
5499
6788
|
window.removeEventListener("scroll", onScroll, true);
|
|
@@ -5530,6 +6819,10 @@ var NuniWidget = class {
|
|
|
5530
6819
|
this.pageUnsub?.();
|
|
5531
6820
|
this.sessionUnsub?.();
|
|
5532
6821
|
this.ownerDetailUnsub?.();
|
|
6822
|
+
this.threadUnsub?.();
|
|
6823
|
+
this.resolveRun++;
|
|
6824
|
+
for (const stop of this.scopeWatchers.values()) stop();
|
|
6825
|
+
this.scopeWatchers.clear();
|
|
5533
6826
|
for (const cleanup of this.cleanups) cleanup();
|
|
5534
6827
|
cancelAnimationFrame(this.layoutFrame);
|
|
5535
6828
|
clearTimeout(this.resolveTimer);
|
|
@@ -5539,7 +6832,7 @@ var NuniWidget = class {
|
|
|
5539
6832
|
}
|
|
5540
6833
|
currentPageKey() {
|
|
5541
6834
|
const url = new URL(location.href);
|
|
5542
|
-
return this.config.getPageKey
|
|
6835
|
+
return this.config.getPageKey(url);
|
|
5543
6836
|
}
|
|
5544
6837
|
subscribePage() {
|
|
5545
6838
|
const key = this.currentPageKey();
|
|
@@ -5557,7 +6850,7 @@ var NuniWidget = class {
|
|
|
5557
6850
|
const merged = extra && !comments.some((c) => c._id === extra._id) ? [...comments, extra] : comments;
|
|
5558
6851
|
this.comments = merged.sort((a, b) => a.createdAt - b.createdAt);
|
|
5559
6852
|
this.loaded = true;
|
|
5560
|
-
this.resolvePlacements(
|
|
6853
|
+
this.resolvePlacements(this.placements.size > 0, this.deepLinkId ?? void 0);
|
|
5561
6854
|
this.openDeepLink();
|
|
5562
6855
|
this.render();
|
|
5563
6856
|
}, () => {});
|
|
@@ -5566,7 +6859,7 @@ var NuniWidget = class {
|
|
|
5566
6859
|
watchSession(token) {
|
|
5567
6860
|
this.sessionUnsub?.();
|
|
5568
6861
|
this.sessionUnsub = this.api.onSession(token, (result) => {
|
|
5569
|
-
if (result.valid) this.ownerName = result.ownerName ?? "
|
|
6862
|
+
if (result.valid) this.ownerName = result.ownerName ?? this.t("owner");
|
|
5570
6863
|
else {
|
|
5571
6864
|
this.ownerName = null;
|
|
5572
6865
|
this.ownerToken = null;
|
|
@@ -5595,6 +6888,21 @@ var NuniWidget = class {
|
|
|
5595
6888
|
this.render();
|
|
5596
6889
|
});
|
|
5597
6890
|
}
|
|
6891
|
+
/** Keep the open thread's replies and reactions subscribed. */
|
|
6892
|
+
syncThread() {
|
|
6893
|
+
const id = this.card?.kind === "thread" ? this.card.id : null;
|
|
6894
|
+
if (id === this.threadId) return;
|
|
6895
|
+
this.threadUnsub?.();
|
|
6896
|
+
this.threadUnsub = null;
|
|
6897
|
+
this.thread = null;
|
|
6898
|
+
this.threadId = id;
|
|
6899
|
+
if (!id) return;
|
|
6900
|
+
this.threadUnsub = this.api.onThread(id, (thread) => {
|
|
6901
|
+
if (this.threadId !== id) return;
|
|
6902
|
+
this.thread = thread;
|
|
6903
|
+
this.render();
|
|
6904
|
+
});
|
|
6905
|
+
}
|
|
5598
6906
|
get isOwner() {
|
|
5599
6907
|
return Boolean(this.ownerToken && this.ownerName);
|
|
5600
6908
|
}
|
|
@@ -5608,38 +6916,165 @@ var NuniWidget = class {
|
|
|
5608
6916
|
clearTimeout(this.resolveTimer);
|
|
5609
6917
|
this.resolveTimer = window.setTimeout(() => this.resolvePlacements(true), 200);
|
|
5610
6918
|
}
|
|
6919
|
+
stillPlaced(comment) {
|
|
6920
|
+
const current = this.placements.get(comment._id);
|
|
6921
|
+
if (!current?.element?.isConnected) return false;
|
|
6922
|
+
const quote = comment.anchor.quote;
|
|
6923
|
+
if (quote) return current.range !== void 0 && collapse(current.range.toString()) === quote.exact;
|
|
6924
|
+
const text = elementText(current.element);
|
|
6925
|
+
const expected = comment.anchor.text;
|
|
6926
|
+
return !expected || text === expected || textSimilarity(text, expected) > .8;
|
|
6927
|
+
}
|
|
5611
6928
|
/**
|
|
5612
6929
|
* Find each comment's element. With `onlyStale`, keep placements whose
|
|
5613
6930
|
* element is still attached and still looks like what was captured.
|
|
6931
|
+
* Works in slices of about 8 ms, so a page with many pins stays
|
|
6932
|
+
* responsive; `first` (a deep-linked comment) is found right away.
|
|
5614
6933
|
*/
|
|
5615
|
-
resolvePlacements(onlyStale) {
|
|
6934
|
+
resolvePlacements(onlyStale, first) {
|
|
6935
|
+
const run = ++this.resolveRun;
|
|
5616
6936
|
const ids = new Set(this.comments.map((c) => c._id));
|
|
5617
6937
|
for (const id of this.placements.keys()) if (!ids.has(id)) this.placements.delete(id);
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
6938
|
+
const queue = this.comments.filter((c) => !(onlyStale && this.stillPlaced(c)));
|
|
6939
|
+
const index = first ? queue.findIndex((c) => c._id === first) : -1;
|
|
6940
|
+
if (index > 0) queue.unshift(...queue.splice(index, 1));
|
|
6941
|
+
const cache = createResolveCache();
|
|
6942
|
+
const options = {
|
|
6943
|
+
isIgnored: this.isIgnored,
|
|
6944
|
+
document
|
|
6945
|
+
};
|
|
6946
|
+
const slice = () => {
|
|
6947
|
+
if (run !== this.resolveRun) return;
|
|
6948
|
+
const started = performance.now();
|
|
6949
|
+
while (queue.length) {
|
|
6950
|
+
const comment = queue.shift();
|
|
6951
|
+
const result = resolveAnchor(comment.anchor, document, options, cache);
|
|
6952
|
+
this.placements.set(comment._id, {
|
|
6953
|
+
element: result.element,
|
|
6954
|
+
confidence: result.confidence,
|
|
6955
|
+
range: result.range
|
|
6956
|
+
});
|
|
6957
|
+
if (performance.now() - started > 8) break;
|
|
6958
|
+
}
|
|
6959
|
+
this.watchScopes();
|
|
6960
|
+
this.renderPins();
|
|
6961
|
+
this.layout();
|
|
6962
|
+
if (queue.length) whenIdle(slice);
|
|
6963
|
+
else if ((onlyStale || run > 1) && this.panelOpen) this.render();
|
|
6964
|
+
};
|
|
6965
|
+
slice();
|
|
6966
|
+
}
|
|
6967
|
+
/**
|
|
6968
|
+
* Pins inside shadow roots and iframes: the page's observers don't see
|
|
6969
|
+
* changes or scrolling in there, so watch each of those scopes too.
|
|
6970
|
+
*/
|
|
6971
|
+
watchScopes() {
|
|
6972
|
+
const roots = /* @__PURE__ */ new Set();
|
|
6973
|
+
for (const { element } of this.placements.values()) {
|
|
6974
|
+
let node = element;
|
|
6975
|
+
for (let depth = 0; node && depth < 8; depth++) {
|
|
6976
|
+
const root = scopeRootOf(node);
|
|
6977
|
+
if (root === document) break;
|
|
6978
|
+
roots.add(root);
|
|
6979
|
+
node = isShadowRoot(root) ? root.host : frameElementOf(root);
|
|
5624
6980
|
}
|
|
5625
|
-
const result = resolveAnchor(comment.anchor, document, { isIgnored: this.isIgnored });
|
|
5626
|
-
this.placements.set(comment._id, {
|
|
5627
|
-
element: result.element,
|
|
5628
|
-
confidence: result.confidence
|
|
5629
|
-
});
|
|
5630
6981
|
}
|
|
5631
|
-
this.
|
|
5632
|
-
|
|
5633
|
-
|
|
6982
|
+
for (const [root, stop] of this.scopeWatchers) if (!roots.has(root)) {
|
|
6983
|
+
stop();
|
|
6984
|
+
this.scopeWatchers.delete(root);
|
|
6985
|
+
}
|
|
6986
|
+
for (const root of roots) if (!this.scopeWatchers.has(root)) this.scopeWatchers.set(root, this.watchScope(root));
|
|
6987
|
+
}
|
|
6988
|
+
watchScope(root) {
|
|
6989
|
+
const observer = new MutationObserver(() => this.scheduleResolve());
|
|
6990
|
+
const doc = isShadowRoot(root) ? null : root;
|
|
6991
|
+
observer.observe(doc ? doc.body ?? doc.documentElement : root, {
|
|
6992
|
+
childList: true,
|
|
6993
|
+
subtree: true,
|
|
6994
|
+
characterData: true,
|
|
6995
|
+
attributes: true,
|
|
6996
|
+
attributeFilter: [
|
|
6997
|
+
"class",
|
|
6998
|
+
"style",
|
|
6999
|
+
"hidden",
|
|
7000
|
+
"open",
|
|
7001
|
+
"id"
|
|
7002
|
+
]
|
|
7003
|
+
});
|
|
7004
|
+
const onScroll = () => this.scheduleLayout();
|
|
7005
|
+
const target = doc?.defaultView ?? root;
|
|
7006
|
+
target.addEventListener("scroll", onScroll, {
|
|
7007
|
+
capture: true,
|
|
7008
|
+
passive: true
|
|
7009
|
+
});
|
|
7010
|
+
doc?.defaultView?.addEventListener("resize", onScroll, { passive: true });
|
|
7011
|
+
return () => {
|
|
7012
|
+
observer.disconnect();
|
|
7013
|
+
target.removeEventListener("scroll", onScroll, true);
|
|
7014
|
+
doc?.defaultView?.removeEventListener("resize", onScroll);
|
|
7015
|
+
};
|
|
5634
7016
|
}
|
|
5635
7017
|
pinPoint(comment) {
|
|
5636
|
-
const
|
|
7018
|
+
const placement = this.placements.get(comment._id);
|
|
7019
|
+
const el = placement?.element;
|
|
5637
7020
|
if (!el || !isRendered(el)) return null;
|
|
5638
|
-
|
|
7021
|
+
if (viewportRect(el, document).clipped) return null;
|
|
7022
|
+
return this.anchorPoint(comment.anchor, el, placement.range);
|
|
7023
|
+
}
|
|
7024
|
+
/**
|
|
7025
|
+
* Where a pin goes: after the last selected word for a text comment, the
|
|
7026
|
+
* top-right corner of an area, or where the element was clicked.
|
|
7027
|
+
*/
|
|
7028
|
+
anchorPoint(anchor, el, range) {
|
|
7029
|
+
if (range && range.startContainer.ownerDocument === document) {
|
|
7030
|
+
const last = lineRects(range).at(-1);
|
|
7031
|
+
if (last) return {
|
|
7032
|
+
x: last.right,
|
|
7033
|
+
y: last.top + last.height / 2
|
|
7034
|
+
};
|
|
7035
|
+
}
|
|
7036
|
+
const r = viewportRect(el, document);
|
|
5639
7037
|
return {
|
|
5640
|
-
x: r.left +
|
|
5641
|
-
y: r.top +
|
|
7038
|
+
x: r.left + anchor.offset.x * r.width,
|
|
7039
|
+
y: r.top + anchor.offset.y * r.height
|
|
7040
|
+
};
|
|
7041
|
+
}
|
|
7042
|
+
/**
|
|
7043
|
+
* Text comments underline their words, and the open comment (or the one
|
|
7044
|
+
* being written) also shows its words or its area strongly.
|
|
7045
|
+
*/
|
|
7046
|
+
renderMarks() {
|
|
7047
|
+
const marks = [];
|
|
7048
|
+
const add = (box, cls) => {
|
|
7049
|
+
const mark = h("div", { class: cls });
|
|
7050
|
+
Object.assign(mark.style, {
|
|
7051
|
+
left: `${box.left}px`,
|
|
7052
|
+
top: `${box.top}px`,
|
|
7053
|
+
width: `${box.width}px`,
|
|
7054
|
+
height: `${box.height}px`
|
|
7055
|
+
});
|
|
7056
|
+
marks.push(mark);
|
|
7057
|
+
};
|
|
7058
|
+
const show = (anchor, el, range, active) => {
|
|
7059
|
+
if (!el) return;
|
|
7060
|
+
if (range && range.startContainer.ownerDocument === document) for (const r of lineRects(range)) add(r, active ? "mark mark-active" : "mark");
|
|
7061
|
+
if (active) {
|
|
7062
|
+
const box = regionBox(anchor, el);
|
|
7063
|
+
if (box) add(box, "area");
|
|
7064
|
+
}
|
|
5642
7065
|
};
|
|
7066
|
+
if (this.card?.kind === "composer") {
|
|
7067
|
+
const { draft } = this.card;
|
|
7068
|
+
show(draft.anchor, draft.element, draft.range, true);
|
|
7069
|
+
}
|
|
7070
|
+
for (const comment of this.visibleComments()) {
|
|
7071
|
+
const placement = this.placements.get(comment._id);
|
|
7072
|
+
const active = this.activeId === comment._id;
|
|
7073
|
+
if (!active && (comment.status !== "open" || !comment.anchor.quote)) continue;
|
|
7074
|
+
if (this.pins.get(comment._id)?.hidden) continue;
|
|
7075
|
+
show(comment.anchor, placement?.element, placement?.range, active);
|
|
7076
|
+
}
|
|
7077
|
+
this.markLayer.replaceChildren(...marks);
|
|
5643
7078
|
}
|
|
5644
7079
|
scheduleLayout() {
|
|
5645
7080
|
if (this.layoutFrame) return;
|
|
@@ -5672,67 +7107,168 @@ var NuniWidget = class {
|
|
|
5672
7107
|
});
|
|
5673
7108
|
pin.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
5674
7109
|
}
|
|
7110
|
+
this.renderMarks();
|
|
5675
7111
|
this.positionCard();
|
|
5676
|
-
if (this.picking && this.hoverEl) this.renderHighlight();
|
|
7112
|
+
if (this.picking && (this.hoverEl || this.dragBox)) this.renderHighlight();
|
|
7113
|
+
this.placeSelectButton();
|
|
5677
7114
|
}
|
|
5678
7115
|
setPicking(on) {
|
|
5679
7116
|
if (this.picking === on) return;
|
|
5680
7117
|
this.picking = on;
|
|
5681
7118
|
const html = document.documentElement;
|
|
7119
|
+
this.dragStart = null;
|
|
7120
|
+
this.dragBox = null;
|
|
5682
7121
|
if (on) {
|
|
5683
7122
|
this.card = null;
|
|
5684
7123
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5685
|
-
|
|
5686
|
-
this.
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
"
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
});
|
|
7124
|
+
this.pickDocs = [document, ...frameDocuments(document)];
|
|
7125
|
+
for (const doc of this.pickDocs) {
|
|
7126
|
+
doc.documentElement.classList.add(PICKING_CLASS);
|
|
7127
|
+
this.injectCursorStyle(doc);
|
|
7128
|
+
doc.addEventListener("pointermove", this.onPickMove, true);
|
|
7129
|
+
doc.addEventListener("click", this.onPickClick, true);
|
|
7130
|
+
for (const type of PICK_EVENTS) doc.addEventListener(type, this.swallow, {
|
|
7131
|
+
capture: true,
|
|
7132
|
+
passive: false
|
|
7133
|
+
});
|
|
7134
|
+
if (doc !== document) doc.addEventListener("keydown", this.onFrameKey, true);
|
|
7135
|
+
}
|
|
5698
7136
|
} else {
|
|
5699
7137
|
html.classList.remove(PICKING_CLASS);
|
|
5700
7138
|
this.hoverEl = null;
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
"
|
|
5705
|
-
|
|
5706
|
-
"
|
|
5707
|
-
|
|
5708
|
-
|
|
7139
|
+
for (const doc of this.pickDocs) {
|
|
7140
|
+
doc.documentElement?.classList.remove(PICKING_CLASS);
|
|
7141
|
+
doc.removeEventListener("pointermove", this.onPickMove, true);
|
|
7142
|
+
doc.removeEventListener("click", this.onPickClick, true);
|
|
7143
|
+
for (const type of PICK_EVENTS) doc.removeEventListener(type, this.swallow, true);
|
|
7144
|
+
doc.removeEventListener("keydown", this.onFrameKey, true);
|
|
7145
|
+
}
|
|
7146
|
+
this.pickDocs = [];
|
|
5709
7147
|
}
|
|
5710
7148
|
this.render();
|
|
5711
7149
|
}
|
|
5712
|
-
injectCursorStyle() {
|
|
5713
|
-
if (
|
|
5714
|
-
const style =
|
|
7150
|
+
injectCursorStyle(doc) {
|
|
7151
|
+
if (doc.getElementById("nuni-cursor-style")) return;
|
|
7152
|
+
const style = doc.createElement("style");
|
|
5715
7153
|
style.id = "nuni-cursor-style";
|
|
5716
7154
|
style.textContent = `html.${PICKING_CLASS}, html.${PICKING_CLASS} * { cursor: crosshair !important; }`;
|
|
5717
|
-
|
|
7155
|
+
(doc.head ?? doc.documentElement).appendChild(style);
|
|
5718
7156
|
this.cleanups.push(() => style.remove());
|
|
5719
7157
|
}
|
|
7158
|
+
/**
|
|
7159
|
+
* The element the event happened on, inside open shadow roots too
|
|
7160
|
+
* (composedPath), and never Nuni's own UI.
|
|
7161
|
+
*/
|
|
5720
7162
|
fromPage(e) {
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
7163
|
+
const path = e.composedPath();
|
|
7164
|
+
if (path.includes(this.host)) return null;
|
|
7165
|
+
const target = path[0] ?? e.target;
|
|
7166
|
+
return target && target.nodeType === 1 ? target : null;
|
|
7167
|
+
}
|
|
7168
|
+
/** An area drawn while picking: anchored to the element that covers it. */
|
|
7169
|
+
commentOnArea(box) {
|
|
7170
|
+
const element = areaContainer(document, box, this.isIgnored);
|
|
7171
|
+
const anchor = captureArea(element, box, {
|
|
7172
|
+
ignoreAttributePrefixes: [],
|
|
7173
|
+
document
|
|
7174
|
+
});
|
|
7175
|
+
this.openComposer({
|
|
7176
|
+
anchor,
|
|
7177
|
+
element
|
|
7178
|
+
});
|
|
7179
|
+
}
|
|
7180
|
+
/** The page's selected text, if it can take a text comment. */
|
|
7181
|
+
pageSelection() {
|
|
7182
|
+
const selection = document.getSelection();
|
|
7183
|
+
if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
|
|
7184
|
+
const range = selection.getRangeAt(0);
|
|
7185
|
+
const node = range.commonAncestorContainer;
|
|
7186
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7187
|
+
if (!el || el === this.host || this.host.contains(el)) return null;
|
|
7188
|
+
if (el.closest("input, textarea, select, [contenteditable]")) return null;
|
|
7189
|
+
if (el.closest("[data-nuni]")) return null;
|
|
7190
|
+
return collapse(range.toString()) ? range : null;
|
|
7191
|
+
}
|
|
7192
|
+
commentOnSelection() {
|
|
7193
|
+
const range = this.pageSelection();
|
|
7194
|
+
if (!range) return;
|
|
7195
|
+
const captured = captureSelection(range, {
|
|
7196
|
+
ignoreAttributePrefixes: [],
|
|
7197
|
+
document
|
|
7198
|
+
});
|
|
7199
|
+
if (!captured) return;
|
|
7200
|
+
const kept = range.cloneRange();
|
|
7201
|
+
document.getSelection()?.removeAllRanges();
|
|
7202
|
+
this.selection = null;
|
|
7203
|
+
this.openComposer({
|
|
7204
|
+
...captured,
|
|
7205
|
+
range: kept
|
|
7206
|
+
});
|
|
7207
|
+
}
|
|
7208
|
+
scheduleSelection() {
|
|
7209
|
+
if (this.selectionFrame) return;
|
|
7210
|
+
this.selectionFrame = requestAnimationFrame(() => {
|
|
7211
|
+
this.selectionFrame = 0;
|
|
7212
|
+
this.selection = this.picking || this.pointerDown ? null : this.pageSelection();
|
|
7213
|
+
this.placeSelectButton();
|
|
7214
|
+
});
|
|
7215
|
+
}
|
|
7216
|
+
/** A small "Comment" button under the selected text. */
|
|
7217
|
+
placeSelectButton() {
|
|
7218
|
+
const button = this.selectButton;
|
|
7219
|
+
const range = this.selection;
|
|
7220
|
+
const last = range ? lineRects(range).at(-1) : void 0;
|
|
7221
|
+
const vw = window.innerWidth;
|
|
7222
|
+
const vh = window.innerHeight;
|
|
7223
|
+
if (!range || !last || last.bottom < 0 || last.top > vh) {
|
|
7224
|
+
button.hidden = true;
|
|
7225
|
+
return;
|
|
7226
|
+
}
|
|
7227
|
+
button.hidden = false;
|
|
7228
|
+
const width = button.offsetWidth || 96;
|
|
7229
|
+
const height = button.offsetHeight || 32;
|
|
7230
|
+
let top = last.bottom + 8;
|
|
7231
|
+
if (top + height > vh - 8) top = last.top - height - 8;
|
|
7232
|
+
const left = Math.max(8, Math.min(last.right - width / 2, vw - width - 8));
|
|
7233
|
+
button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
7234
|
+
}
|
|
7235
|
+
openComposer(draft) {
|
|
7236
|
+
const { element } = draft;
|
|
7237
|
+
this.setPicking(false);
|
|
7238
|
+
if (this.config.capture.dom) try {
|
|
7239
|
+
draft.dom = domContext(element);
|
|
7240
|
+
} catch {}
|
|
7241
|
+
this.card = {
|
|
7242
|
+
kind: "composer",
|
|
7243
|
+
draft
|
|
7244
|
+
};
|
|
7245
|
+
this.activeId = null;
|
|
7246
|
+
this.render();
|
|
7247
|
+
this.focusComposer();
|
|
7248
|
+
this.takeScreenshot(draft);
|
|
5724
7249
|
}
|
|
5725
7250
|
/** Capture while the person types, so posting stays instant. */
|
|
5726
7251
|
async takeScreenshot(draft) {
|
|
5727
7252
|
const load = this.runtime.loadScreenshot;
|
|
5728
|
-
if (!load) return;
|
|
7253
|
+
if (!load || draft.element.ownerDocument !== document) return;
|
|
5729
7254
|
const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
|
|
5730
7255
|
try {
|
|
5731
7256
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
5732
7257
|
const capture = await load();
|
|
5733
|
-
const
|
|
7258
|
+
const { range, anchor, element } = draft;
|
|
7259
|
+
const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
|
|
7260
|
+
const box = regionBox(anchor, element);
|
|
7261
|
+
return {
|
|
7262
|
+
left: box.left,
|
|
7263
|
+
top: box.top,
|
|
7264
|
+
right: box.left + box.width,
|
|
7265
|
+
bottom: box.top + box.height
|
|
7266
|
+
};
|
|
7267
|
+
} : void 0;
|
|
7268
|
+
const blob = capture ? await capture(element, {
|
|
5734
7269
|
exclude: this.host,
|
|
5735
|
-
accent
|
|
7270
|
+
accent,
|
|
7271
|
+
focus
|
|
5736
7272
|
}) : null;
|
|
5737
7273
|
if (!blob || !this.isComposing(draft) || draft.sending) return;
|
|
5738
7274
|
const preview = await dataUrl(blob);
|
|
@@ -5764,15 +7300,31 @@ var NuniWidget = class {
|
|
|
5764
7300
|
}
|
|
5765
7301
|
copyForAgent(comment) {
|
|
5766
7302
|
const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
|
|
5767
|
-
|
|
7303
|
+
const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
|
|
7304
|
+
copyText(buildCommentPrompt(detail ?? {
|
|
7305
|
+
...comment,
|
|
7306
|
+
replies
|
|
7307
|
+
}, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
|
|
5768
7308
|
}
|
|
5769
7309
|
renderHighlight() {
|
|
5770
7310
|
this.overlayLayer.replaceChildren();
|
|
5771
7311
|
if (!this.picking) return;
|
|
5772
|
-
|
|
7312
|
+
const mobile = window.matchMedia(MOBILE_QUERY).matches;
|
|
7313
|
+
this.overlayLayer.append(h("div", { class: "pick-hint" }, mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
|
|
7314
|
+
if (this.dragBox) {
|
|
7315
|
+
const box = h("div", { class: "highlight drag-box" });
|
|
7316
|
+
Object.assign(box.style, {
|
|
7317
|
+
left: `${this.dragBox.left}px`,
|
|
7318
|
+
top: `${this.dragBox.top}px`,
|
|
7319
|
+
width: `${this.dragBox.width}px`,
|
|
7320
|
+
height: `${this.dragBox.height}px`
|
|
7321
|
+
});
|
|
7322
|
+
this.overlayLayer.append(box);
|
|
7323
|
+
return;
|
|
7324
|
+
}
|
|
5773
7325
|
const el = this.hoverEl;
|
|
5774
|
-
if (!el || el ===
|
|
5775
|
-
const r = el
|
|
7326
|
+
if (!el || el === el.ownerDocument.documentElement) return;
|
|
7327
|
+
const r = viewportRect(el, document);
|
|
5776
7328
|
const box = h("div", { class: "highlight" }, h("span", { class: "highlight-label" }, describeElement(el)));
|
|
5777
7329
|
Object.assign(box.style, {
|
|
5778
7330
|
left: `${r.left - 2}px`,
|
|
@@ -5785,19 +7337,171 @@ var NuniWidget = class {
|
|
|
5785
7337
|
onKeyDown(e) {
|
|
5786
7338
|
const inWidget = e.composedPath().includes(this.host);
|
|
5787
7339
|
if (e.key === "Escape") {
|
|
5788
|
-
if (this.
|
|
7340
|
+
if (this.confirming) this.answer(false);
|
|
7341
|
+
else if (this.picking) this.setPicking(false);
|
|
5789
7342
|
else if (this.card) this.closeCard();
|
|
5790
7343
|
else if (this.panelOpen) this.togglePanel(false);
|
|
5791
7344
|
else return;
|
|
5792
7345
|
e.stopPropagation();
|
|
5793
7346
|
return;
|
|
5794
7347
|
}
|
|
5795
|
-
if (
|
|
5796
|
-
if (
|
|
7348
|
+
if (isTypingTarget(e.composedPath()[0] ?? e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
7349
|
+
if (this.picking && !inWidget && this.onPickKey(e)) return;
|
|
7350
|
+
if (inWidget && this.confirming) return;
|
|
7351
|
+
if (e.repeat) return;
|
|
7352
|
+
const hotkey = this.config.hotkey;
|
|
7353
|
+
if (hotkey && e.key.toLowerCase() === hotkey) {
|
|
7354
|
+
e.preventDefault();
|
|
7355
|
+
if (!this.picking && this.pageSelection()) this.commentOnSelection();
|
|
7356
|
+
else this.setPicking(!this.picking);
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
/**
|
|
7360
|
+
* Picking without a mouse: arrows move to the parent, first child and
|
|
7361
|
+
* siblings; Enter comments on the highlighted element.
|
|
7362
|
+
*/
|
|
7363
|
+
onPickKey(e) {
|
|
7364
|
+
if (![
|
|
7365
|
+
"ArrowUp",
|
|
7366
|
+
"ArrowDown",
|
|
7367
|
+
"ArrowLeft",
|
|
7368
|
+
"ArrowRight",
|
|
7369
|
+
"Enter"
|
|
7370
|
+
].includes(e.key)) return false;
|
|
7371
|
+
e.preventDefault();
|
|
7372
|
+
e.stopPropagation();
|
|
7373
|
+
let el = this.hoverEl;
|
|
7374
|
+
if (!el || !el.isConnected) {
|
|
7375
|
+
this.movePickTo(this.keyboardStart());
|
|
7376
|
+
return true;
|
|
7377
|
+
}
|
|
7378
|
+
if (e.key === "Enter") {
|
|
7379
|
+
const r = el.getBoundingClientRect();
|
|
7380
|
+
const anchor = captureAnchor(el, {
|
|
7381
|
+
x: r.left + r.width / 2,
|
|
7382
|
+
y: r.top + r.height / 2
|
|
7383
|
+
}, {
|
|
7384
|
+
ignoreAttributePrefixes: [],
|
|
7385
|
+
document
|
|
7386
|
+
});
|
|
7387
|
+
this.openComposer({
|
|
7388
|
+
anchor,
|
|
7389
|
+
element: el
|
|
7390
|
+
});
|
|
7391
|
+
return true;
|
|
7392
|
+
}
|
|
7393
|
+
const visible = (n) => {
|
|
7394
|
+
while (n && (n === this.host || !isRendered(n))) n = e.key === "ArrowLeft" ? n.previousElementSibling : n.nextElementSibling;
|
|
7395
|
+
return n;
|
|
7396
|
+
};
|
|
7397
|
+
const body = el.ownerDocument.body;
|
|
7398
|
+
if (e.key === "ArrowUp") {
|
|
7399
|
+
if (el.parentElement && el !== body) el = el.parentElement;
|
|
7400
|
+
} else if (e.key === "ArrowDown") el = visible(el.firstElementChild) ?? el;
|
|
7401
|
+
else if (e.key === "ArrowLeft") el = visible(el.previousElementSibling) ?? el;
|
|
7402
|
+
else el = visible(el.nextElementSibling) ?? el;
|
|
7403
|
+
this.movePickTo(el);
|
|
7404
|
+
return true;
|
|
7405
|
+
}
|
|
7406
|
+
/** Keyboard picking starts at the focused element, or mid-screen. */
|
|
7407
|
+
keyboardStart() {
|
|
7408
|
+
const focused = document.activeElement;
|
|
7409
|
+
if (focused && focused !== document.body && focused !== this.host) return focused;
|
|
7410
|
+
const middle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2).find((el) => !this.isIgnored(el));
|
|
7411
|
+
return middle ? pickTarget(middle) : document.body;
|
|
7412
|
+
}
|
|
7413
|
+
movePickTo(el) {
|
|
7414
|
+
this.hoverEl = el;
|
|
7415
|
+
el.scrollIntoView({
|
|
7416
|
+
block: "nearest",
|
|
7417
|
+
inline: "nearest"
|
|
7418
|
+
});
|
|
7419
|
+
this.live.textContent = this.t("picked", { element: describeElement(el) });
|
|
7420
|
+
this.renderHighlight();
|
|
7421
|
+
}
|
|
7422
|
+
/** Tab stays inside an open card, the panel or a dialog. */
|
|
7423
|
+
trapFocus(e) {
|
|
7424
|
+
if (e.key !== "Tab") return;
|
|
7425
|
+
const box = e.composedPath()[0]?.closest?.(".confirm, [data-card], .panel");
|
|
7426
|
+
if (!box) return;
|
|
7427
|
+
const items = Array.from(box.querySelectorAll("button:not([disabled]), input, textarea, a[href], [tabindex=\"0\"]")).filter((el) => !el.closest("[hidden]"));
|
|
7428
|
+
if (!items.length) return;
|
|
7429
|
+
const first = items[0];
|
|
7430
|
+
const last = items[items.length - 1];
|
|
7431
|
+
const active = this.root.activeElement;
|
|
7432
|
+
if (e.shiftKey && active === first) {
|
|
7433
|
+
e.preventDefault();
|
|
7434
|
+
last.focus();
|
|
7435
|
+
} else if (!e.shiftKey && active === last) {
|
|
5797
7436
|
e.preventDefault();
|
|
5798
|
-
|
|
7437
|
+
first.focus();
|
|
5799
7438
|
}
|
|
5800
7439
|
}
|
|
7440
|
+
/** Remember where focus was, before moving it into the widget. */
|
|
7441
|
+
keepFocus() {
|
|
7442
|
+
if (this.returnFocus) return;
|
|
7443
|
+
const inside = this.root.activeElement;
|
|
7444
|
+
if (inside) {
|
|
7445
|
+
this.returnFocus = inside.dataset.focusKey ?? inside;
|
|
7446
|
+
return;
|
|
7447
|
+
}
|
|
7448
|
+
const active = document.activeElement;
|
|
7449
|
+
this.returnFocus = active && active !== document.body && active !== this.host ? active : null;
|
|
7450
|
+
}
|
|
7451
|
+
/** Back to where focus was before (when that is still there). */
|
|
7452
|
+
restoreFocus() {
|
|
7453
|
+
const back = this.returnFocus;
|
|
7454
|
+
this.returnFocus = null;
|
|
7455
|
+
const target = typeof back === "string" ? this.uiLayer.querySelector(`[data-focus-key="${back}"]`) : back;
|
|
7456
|
+
if (target?.isConnected) target.focus({ preventScroll: true });
|
|
7457
|
+
}
|
|
7458
|
+
/** An "are you sure?" dialog inside the widget. */
|
|
7459
|
+
ask(message, action) {
|
|
7460
|
+
this.confirming?.done(false);
|
|
7461
|
+
const from = this.root.activeElement?.dataset.focusKey ?? null;
|
|
7462
|
+
return new Promise((resolve) => {
|
|
7463
|
+
this.confirming = {
|
|
7464
|
+
message,
|
|
7465
|
+
action,
|
|
7466
|
+
done: resolve,
|
|
7467
|
+
from
|
|
7468
|
+
};
|
|
7469
|
+
this.focusNext = "[data-focus-key=\"confirm-cancel\"]";
|
|
7470
|
+
this.render();
|
|
7471
|
+
});
|
|
7472
|
+
}
|
|
7473
|
+
answer(ok) {
|
|
7474
|
+
const confirming = this.confirming;
|
|
7475
|
+
if (!confirming) return;
|
|
7476
|
+
this.confirming = null;
|
|
7477
|
+
if (!ok && confirming.from) this.focusNext = `[data-focus-key="${confirming.from}"]`;
|
|
7478
|
+
this.render();
|
|
7479
|
+
confirming.done(ok);
|
|
7480
|
+
}
|
|
7481
|
+
renderConfirm() {
|
|
7482
|
+
const c = this.confirming;
|
|
7483
|
+
if (!c) return null;
|
|
7484
|
+
return h("div", {
|
|
7485
|
+
class: "confirm-backdrop",
|
|
7486
|
+
onclick: () => this.answer(false)
|
|
7487
|
+
}, h("div", {
|
|
7488
|
+
class: "confirm",
|
|
7489
|
+
role: "alertdialog",
|
|
7490
|
+
"aria-modal": "true",
|
|
7491
|
+
"aria-labelledby": "nuni-confirm-text",
|
|
7492
|
+
onclick: (e) => e.stopPropagation()
|
|
7493
|
+
}, h("p", { id: "nuni-confirm-text" }, c.message), h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
7494
|
+
class: "btn btn-ghost",
|
|
7495
|
+
type: "button",
|
|
7496
|
+
"data-focus-key": "confirm-cancel",
|
|
7497
|
+
onclick: () => this.answer(false)
|
|
7498
|
+
}, this.t("cancel")), h("button", {
|
|
7499
|
+
class: "btn btn-danger-solid",
|
|
7500
|
+
type: "button",
|
|
7501
|
+
"data-focus-key": "confirm-ok",
|
|
7502
|
+
onclick: () => this.answer(true)
|
|
7503
|
+
}, c.action))));
|
|
7504
|
+
}
|
|
5801
7505
|
openClaim() {
|
|
5802
7506
|
const url = new URL(`${this.config.appUrl}/dashboard/claim`);
|
|
5803
7507
|
url.searchParams.set("project", this.config.project);
|
|
@@ -5818,7 +7522,7 @@ var NuniWidget = class {
|
|
|
5818
7522
|
this.ownerToken = data.token;
|
|
5819
7523
|
write(KEYS.session(this.config.project), data.token);
|
|
5820
7524
|
this.watchSession(data.token);
|
|
5821
|
-
this.showToast("
|
|
7525
|
+
this.showToast(this.t("signedIn"));
|
|
5822
7526
|
}
|
|
5823
7527
|
readDeepLink() {
|
|
5824
7528
|
const url = new URL(location.href);
|
|
@@ -5839,12 +7543,12 @@ var NuniWidget = class {
|
|
|
5839
7543
|
}
|
|
5840
7544
|
this.api.getComment(id).then((found) => {
|
|
5841
7545
|
if (!found || found.page.path !== this.pageKey) {
|
|
5842
|
-
this.showToast("
|
|
7546
|
+
this.showToast(this.t("linkGone"));
|
|
5843
7547
|
return;
|
|
5844
7548
|
}
|
|
5845
7549
|
this.extraComment = found;
|
|
5846
7550
|
this.comments = [...this.comments.filter((c) => c._id !== found._id), found];
|
|
5847
|
-
this.resolvePlacements(false);
|
|
7551
|
+
this.resolvePlacements(false, found._id);
|
|
5848
7552
|
this.focusDeepLinked(found);
|
|
5849
7553
|
});
|
|
5850
7554
|
}
|
|
@@ -5852,15 +7556,10 @@ var NuniWidget = class {
|
|
|
5852
7556
|
this.focusComment(comment._id);
|
|
5853
7557
|
}
|
|
5854
7558
|
focusComment(id) {
|
|
5855
|
-
this.placements.get(id)
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
this.activeId = id;
|
|
5860
|
-
this.card = {
|
|
5861
|
-
kind: "thread",
|
|
5862
|
-
id
|
|
5863
|
-
};
|
|
7559
|
+
const placement = this.placements.get(id);
|
|
7560
|
+
const comment = this.comments.find((c) => c._id === id);
|
|
7561
|
+
if (placement?.element && comment) scrollToPin(placement.element, () => this.anchorPoint(comment.anchor, placement.element, placement.range));
|
|
7562
|
+
this.openThread(id);
|
|
5864
7563
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5865
7564
|
this.render();
|
|
5866
7565
|
setTimeout(() => this.scheduleLayout(), 400);
|
|
@@ -5868,9 +7567,9 @@ var NuniWidget = class {
|
|
|
5868
7567
|
async submitDraft(draft, body, name) {
|
|
5869
7568
|
const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
|
|
5870
7569
|
const cleanBody = body.trim();
|
|
5871
|
-
if (!cleanName) return this.setDraftError(draft, "
|
|
5872
|
-
if (!cleanBody) return this.setDraftError(draft, "
|
|
5873
|
-
if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft,
|
|
7570
|
+
if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
|
|
7571
|
+
if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
|
|
7572
|
+
if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
|
|
5874
7573
|
this.name = cleanName;
|
|
5875
7574
|
write(KEYS.name, cleanName);
|
|
5876
7575
|
draft.sending = true;
|
|
@@ -5899,13 +7598,14 @@ var NuniWidget = class {
|
|
|
5899
7598
|
if (shot) this.attachScreenshot(id, shot.blob);
|
|
5900
7599
|
this.placements.set(id, {
|
|
5901
7600
|
element: draft.element,
|
|
5902
|
-
confidence: "exact"
|
|
7601
|
+
confidence: "exact",
|
|
7602
|
+
range: draft.range
|
|
5903
7603
|
});
|
|
5904
7604
|
if (this.card?.kind === "composer" && this.card.draft === draft) this.card = null;
|
|
5905
|
-
this.showToast("
|
|
7605
|
+
this.showToast(this.t("added"));
|
|
5906
7606
|
} catch (error) {
|
|
5907
7607
|
draft.sending = false;
|
|
5908
|
-
draft.error = error instanceof NuniApiError ? error.message :
|
|
7608
|
+
draft.error = error instanceof NuniApiError ? error.message : this.t("postFailed");
|
|
5909
7609
|
}
|
|
5910
7610
|
this.render();
|
|
5911
7611
|
}
|
|
@@ -5933,7 +7633,7 @@ var NuniWidget = class {
|
|
|
5933
7633
|
} catch (error) {
|
|
5934
7634
|
if (this.card?.kind === "thread" && this.card.id === id) {
|
|
5935
7635
|
this.card.busy = false;
|
|
5936
|
-
this.card.error = error instanceof Error ? error.message : "
|
|
7636
|
+
this.card.error = error instanceof Error ? error.message : this.t("failed");
|
|
5937
7637
|
}
|
|
5938
7638
|
this.render();
|
|
5939
7639
|
return false;
|
|
@@ -5942,21 +7642,21 @@ var NuniWidget = class {
|
|
|
5942
7642
|
resolveComment(c) {
|
|
5943
7643
|
const token = this.ownerToken;
|
|
5944
7644
|
if (!token) return;
|
|
5945
|
-
this.act(c._id, () => this.api.resolve(c._id, token), "
|
|
7645
|
+
this.act(c._id, () => this.api.resolve(c._id, token), this.t("resolved")).then((ok) => {
|
|
5946
7646
|
if (ok && !(this.panelOpen && this.tab === "resolved") && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
|
|
5947
7647
|
});
|
|
5948
7648
|
}
|
|
5949
7649
|
reopenComment(c) {
|
|
5950
7650
|
const token = this.ownerToken;
|
|
5951
7651
|
if (!token) return;
|
|
5952
|
-
this.act(c._id, () => this.api.reopen(c._id, token), "
|
|
7652
|
+
this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
|
|
5953
7653
|
}
|
|
5954
|
-
deleteComment(c) {
|
|
5955
|
-
if (!
|
|
7654
|
+
async deleteComment(c) {
|
|
7655
|
+
if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
|
|
5956
7656
|
const token = this.ownerToken;
|
|
5957
7657
|
const action = this.isMine(c) ? () => this.api.deleteOwn(c._id, this.secret) : token ? () => this.api.remove(c._id, token) : null;
|
|
5958
7658
|
if (!action) return;
|
|
5959
|
-
this.act(c._id, action, "
|
|
7659
|
+
this.act(c._id, action, this.t("deleted")).then((ok) => {
|
|
5960
7660
|
if (!ok) return;
|
|
5961
7661
|
if (this.extraComment?._id === c._id) this.extraComment = null;
|
|
5962
7662
|
this.comments = this.comments.filter((x) => x._id !== c._id);
|
|
@@ -5964,8 +7664,72 @@ var NuniWidget = class {
|
|
|
5964
7664
|
this.closeCard();
|
|
5965
7665
|
});
|
|
5966
7666
|
}
|
|
7667
|
+
isMyReply(reply) {
|
|
7668
|
+
return Boolean(this.secretHash) && reply.authorKeyHash === this.secretHash;
|
|
7669
|
+
}
|
|
7670
|
+
async sendReply(comment, body, name) {
|
|
7671
|
+
const card = this.card;
|
|
7672
|
+
if (card?.kind !== "thread" || card.id !== comment._id) return;
|
|
7673
|
+
const authorName = (this.isOwner ? this.ownerName : name)?.trim().slice(0, LIMITS.nameMaxLength);
|
|
7674
|
+
const clean = body.trim();
|
|
7675
|
+
if (!clean) return;
|
|
7676
|
+
if (!authorName) {
|
|
7677
|
+
card.error = this.t("enterName");
|
|
7678
|
+
this.render();
|
|
7679
|
+
return;
|
|
7680
|
+
}
|
|
7681
|
+
if (clean.length > LIMITS.bodyMaxLength) {
|
|
7682
|
+
card.error = this.t("replyTooLong", { count: LIMITS.bodyMaxLength });
|
|
7683
|
+
this.render();
|
|
7684
|
+
return;
|
|
7685
|
+
}
|
|
7686
|
+
if (!this.isOwner) {
|
|
7687
|
+
this.name = authorName;
|
|
7688
|
+
write(KEYS.name, authorName);
|
|
7689
|
+
}
|
|
7690
|
+
card.sendingReply = true;
|
|
7691
|
+
card.error = void 0;
|
|
7692
|
+
this.render();
|
|
7693
|
+
try {
|
|
7694
|
+
await this.api.createReply({
|
|
7695
|
+
commentId: comment._id,
|
|
7696
|
+
body: clean,
|
|
7697
|
+
authorName,
|
|
7698
|
+
authorSecret: this.secret,
|
|
7699
|
+
sessionToken: this.ownerToken ?? void 0
|
|
7700
|
+
});
|
|
7701
|
+
const box = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
7702
|
+
if (box) box.value = "";
|
|
7703
|
+
} catch (error) {
|
|
7704
|
+
card.error = error instanceof NuniApiError ? error.message : this.t("replyFailed");
|
|
7705
|
+
}
|
|
7706
|
+
card.sendingReply = false;
|
|
7707
|
+
this.render();
|
|
7708
|
+
}
|
|
7709
|
+
toggleReaction(comment, targetId, emoji) {
|
|
7710
|
+
this.api.toggleReaction(comment._id, targetId, emoji, this.secret).catch((error) => {
|
|
7711
|
+
if (this.card?.kind === "thread" && this.card.id === comment._id) {
|
|
7712
|
+
this.card.error = error instanceof Error ? error.message : this.t("reactFailed");
|
|
7713
|
+
this.render();
|
|
7714
|
+
}
|
|
7715
|
+
});
|
|
7716
|
+
}
|
|
7717
|
+
async deleteReply(comment, reply) {
|
|
7718
|
+
if (!await this.ask(this.t("confirmDeleteReply"), this.t("delete"))) return;
|
|
7719
|
+
const token = this.ownerToken;
|
|
7720
|
+
const action = this.isMyReply(reply) ? () => this.api.deleteReply(reply._id, this.secret) : token ? () => this.api.removeReply(reply._id, token) : null;
|
|
7721
|
+
if (action) this.act(comment._id, action, this.t("replyDeleted"));
|
|
7722
|
+
}
|
|
7723
|
+
saveReplyEdit(comment, reply, body) {
|
|
7724
|
+
this.act(comment._id, () => this.api.editReply(reply._id, this.secret, body), this.t("saved")).then((ok) => {
|
|
7725
|
+
if (ok && this.card?.kind === "thread") {
|
|
7726
|
+
this.card.replyEditing = void 0;
|
|
7727
|
+
this.render();
|
|
7728
|
+
}
|
|
7729
|
+
});
|
|
7730
|
+
}
|
|
5967
7731
|
saveEdit(c, body) {
|
|
5968
|
-
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), "
|
|
7732
|
+
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
|
|
5969
7733
|
}
|
|
5970
7734
|
showToast(message) {
|
|
5971
7735
|
this.toast = message;
|
|
@@ -5977,17 +7741,36 @@ var NuniWidget = class {
|
|
|
5977
7741
|
this.render();
|
|
5978
7742
|
}
|
|
5979
7743
|
togglePanel(open = !this.panelOpen) {
|
|
7744
|
+
if (open === this.panelOpen) return;
|
|
5980
7745
|
this.panelOpen = open;
|
|
5981
|
-
if (open
|
|
7746
|
+
if (open) {
|
|
7747
|
+
this.keepFocus();
|
|
7748
|
+
this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
|
|
7749
|
+
if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
|
|
7750
|
+
}
|
|
5982
7751
|
this.render();
|
|
7752
|
+
if (!open && !this.card) this.restoreFocus();
|
|
5983
7753
|
}
|
|
5984
7754
|
closeCard() {
|
|
7755
|
+
const had = this.card !== null;
|
|
5985
7756
|
this.card = null;
|
|
5986
7757
|
this.activeId = null;
|
|
5987
7758
|
this.render();
|
|
7759
|
+
if (had && !this.panelOpen) this.restoreFocus();
|
|
7760
|
+
}
|
|
7761
|
+
/** Open a comment's thread and move focus into it. */
|
|
7762
|
+
openThread(id) {
|
|
7763
|
+
this.keepFocus();
|
|
7764
|
+
this.activeId = id;
|
|
7765
|
+
this.card = {
|
|
7766
|
+
kind: "thread",
|
|
7767
|
+
id
|
|
7768
|
+
};
|
|
7769
|
+
this.focusNext = "[data-card=\"thread\"] .card-head button";
|
|
5988
7770
|
}
|
|
5989
7771
|
render() {
|
|
5990
7772
|
this.syncOwnerDetail();
|
|
7773
|
+
this.syncThread();
|
|
5991
7774
|
this.renderPins();
|
|
5992
7775
|
this.renderUi();
|
|
5993
7776
|
this.renderHighlight();
|
|
@@ -6013,11 +7796,7 @@ var NuniWidget = class {
|
|
|
6013
7796
|
e.stopPropagation();
|
|
6014
7797
|
if (this.card?.kind === "thread" && this.card.id === id) this.closeCard();
|
|
6015
7798
|
else {
|
|
6016
|
-
this.
|
|
6017
|
-
this.card = {
|
|
6018
|
-
kind: "thread",
|
|
6019
|
-
id
|
|
6020
|
-
};
|
|
7799
|
+
this.openThread(id);
|
|
6021
7800
|
this.render();
|
|
6022
7801
|
}
|
|
6023
7802
|
});
|
|
@@ -6026,7 +7805,12 @@ var NuniWidget = class {
|
|
|
6026
7805
|
}
|
|
6027
7806
|
pin.textContent = initials(comment.authorName);
|
|
6028
7807
|
pin.style.background = colorFor(comment.authorName);
|
|
6029
|
-
pin.setAttribute("aria-label",
|
|
7808
|
+
pin.setAttribute("aria-label", this.t("pinLabel", {
|
|
7809
|
+
name: comment.authorName,
|
|
7810
|
+
body: comment.body.slice(0, 80)
|
|
7811
|
+
}));
|
|
7812
|
+
if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
|
|
7813
|
+
else delete pin.dataset.replies;
|
|
6030
7814
|
pin.dataset.status = comment.status;
|
|
6031
7815
|
pin.dataset.active = String(this.activeId === comment._id);
|
|
6032
7816
|
pin.dataset.confidence = this.placements.get(comment._id)?.confidence ?? "lost";
|
|
@@ -6034,9 +7818,7 @@ var NuniWidget = class {
|
|
|
6034
7818
|
this.pinLayer.querySelector(".pin-draft")?.remove();
|
|
6035
7819
|
if (this.card?.kind === "composer") {
|
|
6036
7820
|
const { draft } = this.card;
|
|
6037
|
-
const
|
|
6038
|
-
const x = r.left + draft.anchor.offset.x * r.width;
|
|
6039
|
-
const y = r.top + draft.anchor.offset.y * r.height;
|
|
7821
|
+
const { x, y } = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
6040
7822
|
const pin = h("div", {
|
|
6041
7823
|
class: "pin pin-draft pin-pending",
|
|
6042
7824
|
"aria-hidden": "true"
|
|
@@ -6055,6 +7837,8 @@ var NuniWidget = class {
|
|
|
6055
7837
|
class: "toast",
|
|
6056
7838
|
role: "status"
|
|
6057
7839
|
}, this.toast));
|
|
7840
|
+
const confirm = this.renderConfirm();
|
|
7841
|
+
if (confirm) nodes.push(confirm);
|
|
6058
7842
|
const focused = this.root.activeElement;
|
|
6059
7843
|
const focusKey = focused?.dataset.focusKey;
|
|
6060
7844
|
const selection = focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement ? {
|
|
@@ -6062,7 +7846,11 @@ var NuniWidget = class {
|
|
|
6062
7846
|
end: focused.selectionEnd
|
|
6063
7847
|
} : null;
|
|
6064
7848
|
this.uiLayer.replaceChildren(...nodes);
|
|
6065
|
-
|
|
7849
|
+
const next = this.focusNext;
|
|
7850
|
+
this.focusNext = null;
|
|
7851
|
+
const wanted = next ? this.uiLayer.querySelector(next) : null;
|
|
7852
|
+
if (wanted) wanted.focus();
|
|
7853
|
+
else if (focusKey) {
|
|
6066
7854
|
const next = this.uiLayer.querySelector(`[data-focus-key="${focusKey}"]`);
|
|
6067
7855
|
next?.focus();
|
|
6068
7856
|
if (selection && (next instanceof HTMLTextAreaElement || next instanceof HTMLInputElement)) next.setSelectionRange(selection.start, selection.end);
|
|
@@ -6074,20 +7862,22 @@ var NuniWidget = class {
|
|
|
6074
7862
|
return h("div", {
|
|
6075
7863
|
class: "toolbar",
|
|
6076
7864
|
role: "toolbar",
|
|
6077
|
-
"aria-label": "
|
|
7865
|
+
"aria-label": this.t("toolbar")
|
|
6078
7866
|
}, h("button", {
|
|
6079
7867
|
class: "tb-btn",
|
|
6080
7868
|
type: "button",
|
|
6081
7869
|
"aria-pressed": String(this.picking),
|
|
6082
|
-
"
|
|
6083
|
-
|
|
7870
|
+
"data-focus-key": "tb-comment",
|
|
7871
|
+
"aria-label": this.picking ? this.t("cancelAdding") : this.t("addComment"),
|
|
7872
|
+
title: this.withKey("addComment", "addCommentKey"),
|
|
6084
7873
|
onclick: () => this.setPicking(!this.picking)
|
|
6085
|
-
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? "
|
|
7874
|
+
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? this.t("pickElement") : this.config.label ?? this.t("comment"))), h("button", {
|
|
6086
7875
|
class: "tb-btn",
|
|
6087
7876
|
type: "button",
|
|
6088
7877
|
"aria-pressed": String(this.panelOpen),
|
|
6089
|
-
"
|
|
6090
|
-
|
|
7878
|
+
"data-focus-key": "tb-panel",
|
|
7879
|
+
"aria-label": this.t("openCount", { count: open }),
|
|
7880
|
+
title: this.t("allComments"),
|
|
6091
7881
|
onclick: () => this.togglePanel()
|
|
6092
7882
|
}, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
|
|
6093
7883
|
}
|
|
@@ -6101,72 +7891,87 @@ var NuniWidget = class {
|
|
|
6101
7891
|
const item = (c) => h("button", {
|
|
6102
7892
|
class: "item",
|
|
6103
7893
|
type: "button",
|
|
7894
|
+
"data-focus-key": `item-${c._id}`,
|
|
6104
7895
|
onclick: () => this.focusComment(c._id)
|
|
6105
7896
|
}, h("div", { class: "row" }, h("span", {
|
|
6106
7897
|
class: "avatar",
|
|
6107
7898
|
style: `background:${colorFor(c.authorName)}`
|
|
6108
|
-
}, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, timeAgo(c.createdAt)), c.page.origin !== location.origin ? h("span", {
|
|
7899
|
+
}, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt) + (c.replyCount ? ` · ${this.t("replyCount", { count: c.replyCount })}` : "")), c.page.origin !== location.origin ? h("span", {
|
|
6109
7900
|
class: "badge",
|
|
6110
7901
|
title: c.page.origin
|
|
6111
|
-
}, new URL(c.page.origin).host) : null), h("div", { class: "item-text" }, c.body));
|
|
7902
|
+
}, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
|
|
6112
7903
|
const children = [];
|
|
6113
|
-
if (!this.loaded) children.push(h("div", { class: "empty" }, "
|
|
6114
|
-
else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ?
|
|
7904
|
+
if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
|
|
7905
|
+
else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
|
|
6115
7906
|
else {
|
|
6116
7907
|
children.push(...placed.map(item));
|
|
6117
7908
|
if (lost.length) {
|
|
6118
|
-
children.push(h("div", { class: "section-label" },
|
|
7909
|
+
children.push(h("div", { class: "section-label" }, this.t("notFoundHere")));
|
|
6119
7910
|
children.push(...lost.map(item));
|
|
6120
7911
|
}
|
|
6121
7912
|
}
|
|
6122
7913
|
if (otherPages.length) {
|
|
6123
|
-
children.push(h("div", { class: "section-label" }, "
|
|
7914
|
+
children.push(h("div", { class: "section-label" }, this.t("otherPages")));
|
|
6124
7915
|
for (const p of otherPages.slice(0, 20)) {
|
|
6125
|
-
const href =
|
|
6126
|
-
children.push(h("a", {
|
|
7916
|
+
const href = otherPageHref(p.path, location);
|
|
7917
|
+
children.push(h(href === null ? "div" : "a", href === null ? { class: "item" } : {
|
|
6127
7918
|
class: "item",
|
|
6128
7919
|
href
|
|
6129
|
-
}, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" },
|
|
7920
|
+
}, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, this.t("openOnPage", { count: p.count })))));
|
|
6130
7921
|
}
|
|
6131
7922
|
}
|
|
6132
7923
|
return h("div", {
|
|
6133
7924
|
class: "panel",
|
|
6134
7925
|
role: "dialog",
|
|
6135
|
-
"aria-label": "
|
|
6136
|
-
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, "
|
|
7926
|
+
"aria-label": this.t("comments")
|
|
7927
|
+
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, this.t("comments")), h("button", {
|
|
6137
7928
|
class: "btn btn-ghost btn-icon",
|
|
6138
7929
|
type: "button",
|
|
6139
|
-
"aria-label": "
|
|
7930
|
+
"aria-label": this.t("close"),
|
|
6140
7931
|
onclick: () => this.togglePanel(false)
|
|
6141
7932
|
}, icon(ICONS.close))), h("div", {
|
|
6142
7933
|
class: "tabs",
|
|
6143
|
-
role: "tablist"
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
7934
|
+
role: "tablist",
|
|
7935
|
+
"aria-label": this.t("comments"),
|
|
7936
|
+
onkeydown: (e) => {
|
|
7937
|
+
const key = e.key;
|
|
7938
|
+
if (![
|
|
7939
|
+
"ArrowLeft",
|
|
7940
|
+
"ArrowRight",
|
|
7941
|
+
"Home",
|
|
7942
|
+
"End"
|
|
7943
|
+
].includes(key)) return;
|
|
7944
|
+
e.preventDefault();
|
|
7945
|
+
this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
|
|
7946
|
+
this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
|
|
6151
7947
|
this.render();
|
|
6152
7948
|
}
|
|
6153
|
-
},
|
|
7949
|
+
}, ["open", "resolved"].map((tab) => h("button", {
|
|
6154
7950
|
class: "tab",
|
|
6155
7951
|
role: "tab",
|
|
6156
7952
|
type: "button",
|
|
6157
|
-
|
|
7953
|
+
id: `nuni-tab-${tab}`,
|
|
7954
|
+
"data-focus-key": `tab-${tab}`,
|
|
7955
|
+
"aria-selected": String(this.tab === tab),
|
|
7956
|
+
"aria-controls": "nuni-panel-list",
|
|
7957
|
+
tabindex: this.tab === tab ? 0 : -1,
|
|
6158
7958
|
onclick: () => {
|
|
6159
|
-
this.tab =
|
|
7959
|
+
this.tab = tab;
|
|
6160
7960
|
this.render();
|
|
6161
7961
|
}
|
|
6162
|
-
},
|
|
7962
|
+
}, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
|
|
7963
|
+
class: "panel-list",
|
|
7964
|
+
id: "nuni-panel-list",
|
|
7965
|
+
role: "tabpanel",
|
|
7966
|
+
"aria-labelledby": `nuni-tab-${this.tab}`
|
|
7967
|
+
}, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
|
|
6163
7968
|
}
|
|
6164
7969
|
renderPanelFooter() {
|
|
6165
|
-
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" },
|
|
7970
|
+
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, this.t("ownSite")), h("span", { class: "spacer" }), h("button", {
|
|
6166
7971
|
class: "btn",
|
|
6167
7972
|
type: "button",
|
|
6168
7973
|
onclick: () => this.openClaim()
|
|
6169
|
-
}, icon(ICONS.github), "
|
|
7974
|
+
}, icon(ICONS.github), this.t("claim"))));
|
|
6170
7975
|
}
|
|
6171
7976
|
renderCard() {
|
|
6172
7977
|
const card = this.card;
|
|
@@ -6177,19 +7982,19 @@ var NuniWidget = class {
|
|
|
6177
7982
|
const needsName = !this.name;
|
|
6178
7983
|
const nameInput = h("input", {
|
|
6179
7984
|
class: "field",
|
|
6180
|
-
placeholder: "
|
|
7985
|
+
placeholder: this.t("yourName"),
|
|
6181
7986
|
value: this.name,
|
|
6182
7987
|
maxlength: LIMITS.nameMaxLength,
|
|
6183
7988
|
autocomplete: "name",
|
|
6184
7989
|
"data-focus-key": "name",
|
|
6185
|
-
"aria-label": "
|
|
7990
|
+
"aria-label": this.t("yourName")
|
|
6186
7991
|
});
|
|
6187
7992
|
const textarea = h("textarea", {
|
|
6188
7993
|
class: "field",
|
|
6189
|
-
placeholder: "
|
|
7994
|
+
placeholder: this.t("leaveComment"),
|
|
6190
7995
|
maxlength: LIMITS.bodyMaxLength,
|
|
6191
7996
|
"data-focus-key": "body",
|
|
6192
|
-
"aria-label": "
|
|
7997
|
+
"aria-label": this.t("comment")
|
|
6193
7998
|
});
|
|
6194
7999
|
const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
|
|
6195
8000
|
if (prev) textarea.value = prev.value;
|
|
@@ -6219,28 +8024,28 @@ var NuniWidget = class {
|
|
|
6219
8024
|
this.name = "";
|
|
6220
8025
|
this.render();
|
|
6221
8026
|
}
|
|
6222
|
-
}, "
|
|
8027
|
+
}, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
|
|
6223
8028
|
src: draft.screenshot.preview,
|
|
6224
|
-
alt: "
|
|
6225
|
-
}), h("div", { class: "shot-note" }, h("span", {},
|
|
8029
|
+
alt: this.t("screenshotAlt")
|
|
8030
|
+
}), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
|
|
6226
8031
|
class: "link",
|
|
6227
8032
|
type: "button",
|
|
6228
8033
|
onclick: () => {
|
|
6229
8034
|
draft.screenshot = void 0;
|
|
6230
8035
|
this.render();
|
|
6231
8036
|
}
|
|
6232
|
-
}, "
|
|
8037
|
+
}, this.t("remove")))) : null, draft.error ? h("div", {
|
|
6233
8038
|
class: "error",
|
|
6234
8039
|
role: "alert"
|
|
6235
|
-
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "
|
|
8040
|
+
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, this.t("send", { key: this.i18n.mod })), h("span", { class: "spacer" }), h("button", {
|
|
6236
8041
|
class: "btn btn-ghost",
|
|
6237
8042
|
type: "button",
|
|
6238
8043
|
onclick: () => this.closeCard()
|
|
6239
|
-
}, "
|
|
8044
|
+
}, this.t("cancel")), h("button", {
|
|
6240
8045
|
class: "btn btn-primary",
|
|
6241
8046
|
type: "submit",
|
|
6242
8047
|
disabled: Boolean(draft.sending)
|
|
6243
|
-
}, icon(ICONS.send), draft.sending ? "
|
|
8048
|
+
}, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
|
|
6244
8049
|
}
|
|
6245
8050
|
const comment = this.comments.find((c) => c._id === card.id);
|
|
6246
8051
|
if (!comment) return null;
|
|
@@ -6263,39 +8068,39 @@ var NuniWidget = class {
|
|
|
6263
8068
|
card.editing = false;
|
|
6264
8069
|
this.render();
|
|
6265
8070
|
}
|
|
6266
|
-
}, "
|
|
8071
|
+
}, this.t("cancel")), h("button", {
|
|
6267
8072
|
class: "btn btn-primary",
|
|
6268
8073
|
type: "button",
|
|
6269
8074
|
disabled: Boolean(card.busy),
|
|
6270
8075
|
onclick: () => this.saveEdit(comment, textarea.value)
|
|
6271
|
-
}, "
|
|
8076
|
+
}, this.t("save"))));
|
|
6272
8077
|
} else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
|
|
6273
8078
|
class: "avatar",
|
|
6274
8079
|
style: `background:${colorFor(comment.authorName)}`
|
|
6275
|
-
}, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, timeAgo(comment.createdAt) + (comment.editedAt ?
|
|
8080
|
+
}, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("button", {
|
|
6276
8081
|
class: "btn btn-ghost btn-icon",
|
|
6277
8082
|
type: "button",
|
|
6278
|
-
"aria-label": "
|
|
8083
|
+
"aria-label": this.t("close"),
|
|
6279
8084
|
onclick: () => this.closeCard()
|
|
6280
|
-
}, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
|
|
8085
|
+
}, icon(ICONS.close))), comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
|
|
6281
8086
|
class: "shot",
|
|
6282
8087
|
href: this.ownerDetail.screenshotUrl,
|
|
6283
8088
|
target: "_blank",
|
|
6284
8089
|
rel: "noreferrer",
|
|
6285
|
-
title: "
|
|
8090
|
+
title: this.t("openScreenshot")
|
|
6286
8091
|
}, h("img", {
|
|
6287
8092
|
src: this.ownerDetail.screenshotUrl,
|
|
6288
|
-
alt: "
|
|
6289
|
-
})) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), "
|
|
8093
|
+
alt: this.t("screenshotTaken")
|
|
8094
|
+
})) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null, comment.page.origin !== location.origin ? h("span", {
|
|
6290
8095
|
class: "badge",
|
|
6291
|
-
title:
|
|
8096
|
+
title: this.t("leftOn", { origin: comment.page.origin })
|
|
6292
8097
|
}, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
|
|
6293
8098
|
class: "badge",
|
|
6294
|
-
title:
|
|
6295
|
-
}, "
|
|
8099
|
+
title: this.t("approximateHint")
|
|
8100
|
+
}, this.t("approximate")) : null, !placement?.element ? h("span", {
|
|
6296
8101
|
class: "badge",
|
|
6297
|
-
title:
|
|
6298
|
-
}, "
|
|
8102
|
+
title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
|
|
8103
|
+
}, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
|
|
6299
8104
|
class: "error",
|
|
6300
8105
|
role: "alert"
|
|
6301
8106
|
}, card.error) : null);
|
|
@@ -6306,27 +8111,27 @@ var NuniWidget = class {
|
|
|
6306
8111
|
type: "button",
|
|
6307
8112
|
disabled: Boolean(card.busy),
|
|
6308
8113
|
onclick: () => this.resolveComment(comment)
|
|
6309
|
-
}, icon(ICONS.check), "
|
|
8114
|
+
}, icon(ICONS.check), this.t("resolve")) : h("button", {
|
|
6310
8115
|
class: "btn",
|
|
6311
8116
|
type: "button",
|
|
6312
8117
|
disabled: Boolean(card.busy),
|
|
6313
8118
|
onclick: () => this.reopenComment(comment)
|
|
6314
|
-
}, icon(ICONS.undo), "
|
|
8119
|
+
}, icon(ICONS.undo), this.t("reopen")));
|
|
6315
8120
|
actions.push(h("span", { class: "spacer" }));
|
|
6316
8121
|
const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
|
|
6317
8122
|
actions.push(h("button", {
|
|
6318
8123
|
class: "btn btn-ghost btn-icon",
|
|
6319
8124
|
type: "button",
|
|
6320
|
-
"aria-label": "
|
|
6321
|
-
title: contextPending ? "
|
|
8125
|
+
"aria-label": this.t("copyForAgent"),
|
|
8126
|
+
title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
|
|
6322
8127
|
disabled: contextPending,
|
|
6323
8128
|
onclick: () => this.copyForAgent(comment)
|
|
6324
8129
|
}, icon(ICONS.bot)));
|
|
6325
8130
|
if (mine) actions.push(h("button", {
|
|
6326
8131
|
class: "btn btn-ghost btn-icon",
|
|
6327
8132
|
type: "button",
|
|
6328
|
-
"aria-label": "
|
|
6329
|
-
title: "
|
|
8133
|
+
"aria-label": this.t("edit"),
|
|
8134
|
+
title: this.t("edit"),
|
|
6330
8135
|
onclick: () => {
|
|
6331
8136
|
card.editing = true;
|
|
6332
8137
|
this.render();
|
|
@@ -6335,18 +8140,164 @@ var NuniWidget = class {
|
|
|
6335
8140
|
if (mine || owner) actions.push(h("button", {
|
|
6336
8141
|
class: "btn btn-ghost btn-icon btn-danger",
|
|
6337
8142
|
type: "button",
|
|
6338
|
-
"aria-label": "
|
|
6339
|
-
title: "
|
|
8143
|
+
"aria-label": this.t("delete"),
|
|
8144
|
+
title: this.t("delete"),
|
|
8145
|
+
"data-focus-key": "delete",
|
|
6340
8146
|
disabled: Boolean(card.busy),
|
|
6341
|
-
onclick: () => this.deleteComment(comment)
|
|
8147
|
+
onclick: () => void this.deleteComment(comment)
|
|
6342
8148
|
}, icon(ICONS.trash)));
|
|
6343
8149
|
}
|
|
6344
8150
|
return h("div", {
|
|
6345
8151
|
class: cls,
|
|
6346
8152
|
"data-card": "thread",
|
|
6347
8153
|
role: "dialog",
|
|
6348
|
-
"aria-label":
|
|
6349
|
-
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
|
|
8154
|
+
"aria-label": this.t("commentBy", { name: comment.authorName })
|
|
8155
|
+
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
|
|
8156
|
+
}
|
|
8157
|
+
/** Emoji chips for one message, plus a picker to add one. */
|
|
8158
|
+
renderReactions(comment, targetId) {
|
|
8159
|
+
const card = this.card;
|
|
8160
|
+
if (card?.kind !== "thread") return null;
|
|
8161
|
+
const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
|
|
8162
|
+
const open = card.picker === targetId;
|
|
8163
|
+
return h("div", { class: "reactions" }, ...summaries.map((r) => {
|
|
8164
|
+
const mine = r.authorKeyHashes.includes(this.secretHash);
|
|
8165
|
+
return h("button", {
|
|
8166
|
+
class: "reaction",
|
|
8167
|
+
type: "button",
|
|
8168
|
+
"aria-pressed": String(mine),
|
|
8169
|
+
"aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
|
|
8170
|
+
onclick: () => this.toggleReaction(comment, targetId, r.emoji)
|
|
8171
|
+
}, `${r.emoji} ${r.count}`);
|
|
8172
|
+
}), h("button", {
|
|
8173
|
+
class: "reaction reaction-add",
|
|
8174
|
+
type: "button",
|
|
8175
|
+
"aria-label": this.t("addReaction"),
|
|
8176
|
+
"aria-expanded": String(open),
|
|
8177
|
+
title: this.t("addReaction"),
|
|
8178
|
+
onclick: () => {
|
|
8179
|
+
card.picker = open ? null : targetId;
|
|
8180
|
+
this.render();
|
|
8181
|
+
}
|
|
8182
|
+
}, icon(ICONS.smile)), open ? h("div", {
|
|
8183
|
+
class: "reaction-picker",
|
|
8184
|
+
role: "group",
|
|
8185
|
+
"aria-label": this.t("reactions")
|
|
8186
|
+
}, ...REACTIONS.map((emoji) => h("button", {
|
|
8187
|
+
class: "reaction",
|
|
8188
|
+
type: "button",
|
|
8189
|
+
"aria-label": this.t("reactWith", { emoji }),
|
|
8190
|
+
onclick: () => {
|
|
8191
|
+
card.picker = null;
|
|
8192
|
+
this.toggleReaction(comment, targetId, emoji);
|
|
8193
|
+
this.render();
|
|
8194
|
+
}
|
|
8195
|
+
}, emoji))) : null);
|
|
8196
|
+
}
|
|
8197
|
+
/** The replies under a comment and the box to add one. */
|
|
8198
|
+
renderThread(comment) {
|
|
8199
|
+
const card = this.card;
|
|
8200
|
+
if (card?.kind !== "thread") return null;
|
|
8201
|
+
const replies = this.threadId === comment._id ? this.thread?.replies ?? [] : [];
|
|
8202
|
+
const owner = this.isOwner;
|
|
8203
|
+
const items = replies.map((reply) => {
|
|
8204
|
+
const mine = this.isMyReply(reply);
|
|
8205
|
+
if (card.replyEditing === reply._id) {
|
|
8206
|
+
const box = h("textarea", {
|
|
8207
|
+
class: "field",
|
|
8208
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8209
|
+
"data-focus-key": `reply-edit-${reply._id}`,
|
|
8210
|
+
"aria-label": this.t("editReply")
|
|
8211
|
+
});
|
|
8212
|
+
const prev = this.uiLayer.querySelector(`[data-focus-key="reply-edit-${reply._id}"]`);
|
|
8213
|
+
box.value = prev ? prev.value : reply.body;
|
|
8214
|
+
return h("div", { class: "reply" }, box, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
8215
|
+
class: "btn btn-ghost",
|
|
8216
|
+
type: "button",
|
|
8217
|
+
onclick: () => {
|
|
8218
|
+
card.replyEditing = void 0;
|
|
8219
|
+
this.render();
|
|
8220
|
+
}
|
|
8221
|
+
}, this.t("cancel")), h("button", {
|
|
8222
|
+
class: "btn btn-primary",
|
|
8223
|
+
type: "button",
|
|
8224
|
+
disabled: Boolean(card.busy),
|
|
8225
|
+
onclick: () => this.saveReplyEdit(comment, reply, box.value)
|
|
8226
|
+
}, this.t("save"))));
|
|
8227
|
+
}
|
|
8228
|
+
return h("div", {
|
|
8229
|
+
class: "reply",
|
|
8230
|
+
"data-reply": reply._id
|
|
8231
|
+
}, h("div", { class: "card-head" }, h("span", {
|
|
8232
|
+
class: "avatar avatar-sm",
|
|
8233
|
+
style: `background:${colorFor(reply.authorName)}`
|
|
8234
|
+
}, initials(reply.authorName)), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), mine ? h("button", {
|
|
8235
|
+
class: "btn btn-ghost btn-icon btn-xs",
|
|
8236
|
+
type: "button",
|
|
8237
|
+
"aria-label": this.t("editReply"),
|
|
8238
|
+
title: this.t("edit"),
|
|
8239
|
+
onclick: () => {
|
|
8240
|
+
card.replyEditing = reply._id;
|
|
8241
|
+
this.render();
|
|
8242
|
+
}
|
|
8243
|
+
}, icon(ICONS.edit)) : null, mine || owner ? h("button", {
|
|
8244
|
+
class: "btn btn-ghost btn-icon btn-xs btn-danger",
|
|
8245
|
+
type: "button",
|
|
8246
|
+
"aria-label": this.t("deleteReply"),
|
|
8247
|
+
title: this.t("delete"),
|
|
8248
|
+
"data-focus-key": `delete-${reply._id}`,
|
|
8249
|
+
disabled: Boolean(card.busy),
|
|
8250
|
+
onclick: () => void this.deleteReply(comment, reply)
|
|
8251
|
+
}, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
|
|
8252
|
+
});
|
|
8253
|
+
const needsName = !owner && !this.name;
|
|
8254
|
+
const nameInput = h("input", {
|
|
8255
|
+
class: "field",
|
|
8256
|
+
placeholder: this.t("yourName"),
|
|
8257
|
+
value: this.name,
|
|
8258
|
+
maxlength: LIMITS.nameMaxLength,
|
|
8259
|
+
autocomplete: "name",
|
|
8260
|
+
"data-focus-key": "reply-name",
|
|
8261
|
+
"aria-label": this.t("yourName")
|
|
8262
|
+
});
|
|
8263
|
+
const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
|
|
8264
|
+
if (prevName) nameInput.value = prevName.value;
|
|
8265
|
+
const box = h("textarea", {
|
|
8266
|
+
class: "field field-reply",
|
|
8267
|
+
placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
|
|
8268
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8269
|
+
"data-focus-key": "reply",
|
|
8270
|
+
"aria-label": this.t("reply")
|
|
8271
|
+
});
|
|
8272
|
+
const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
8273
|
+
if (prev) box.value = prev.value;
|
|
8274
|
+
const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
|
|
8275
|
+
box.addEventListener("keydown", (e) => {
|
|
8276
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
8277
|
+
e.preventDefault();
|
|
8278
|
+
send();
|
|
8279
|
+
}
|
|
8280
|
+
});
|
|
8281
|
+
return h("div", { class: "thread" }, items.length ? h("div", {
|
|
8282
|
+
class: "replies",
|
|
8283
|
+
role: "list",
|
|
8284
|
+
"aria-label": this.t("replyCount", { count: items.length })
|
|
8285
|
+
}, ...items.map((item) => {
|
|
8286
|
+
item.setAttribute("role", "listitem");
|
|
8287
|
+
return item;
|
|
8288
|
+
})) : null, h("form", {
|
|
8289
|
+
class: "reply-form",
|
|
8290
|
+
onsubmit: (e) => {
|
|
8291
|
+
e.preventDefault();
|
|
8292
|
+
send();
|
|
8293
|
+
}
|
|
8294
|
+
}, needsName ? nameInput : null, box, h("button", {
|
|
8295
|
+
class: "btn btn-primary btn-icon",
|
|
8296
|
+
type: "submit",
|
|
8297
|
+
"aria-label": this.t("sendReply"),
|
|
8298
|
+
title: this.t("sendReply"),
|
|
8299
|
+
disabled: Boolean(card.sendingReply)
|
|
8300
|
+
}, icon(ICONS.send))));
|
|
6350
8301
|
}
|
|
6351
8302
|
positionCard() {
|
|
6352
8303
|
const el = this.uiLayer.querySelector("[data-card]");
|
|
@@ -6354,11 +8305,7 @@ var NuniWidget = class {
|
|
|
6354
8305
|
let point = null;
|
|
6355
8306
|
if (this.card?.kind === "composer") {
|
|
6356
8307
|
const { draft } = this.card;
|
|
6357
|
-
|
|
6358
|
-
point = {
|
|
6359
|
-
x: r.left + draft.anchor.offset.x * r.width,
|
|
6360
|
-
y: r.top + draft.anchor.offset.y * r.height
|
|
6361
|
-
};
|
|
8308
|
+
point = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
6362
8309
|
} else if (this.card?.kind === "thread") {
|
|
6363
8310
|
const comment = this.comments.find((c) => this.card?.kind === "thread" && c._id === this.card.id);
|
|
6364
8311
|
point = comment ? this.pinPositions.get(comment._id) ?? this.pinPoint(comment) : null;
|
|
@@ -6374,8 +8321,9 @@ var NuniWidget = class {
|
|
|
6374
8321
|
top = point.y - 28;
|
|
6375
8322
|
if (left + width > vw - 12) left = point.x - width - 12;
|
|
6376
8323
|
} else {
|
|
6377
|
-
|
|
6378
|
-
|
|
8324
|
+
const { position } = this.config;
|
|
8325
|
+
left = position.endsWith("left") ? 16 : vw - width - 16;
|
|
8326
|
+
top = position.startsWith("top") ? 72 : vh - height - 72;
|
|
6379
8327
|
}
|
|
6380
8328
|
left = Math.max(12, Math.min(left, vw - width - 12));
|
|
6381
8329
|
top = Math.max(12, Math.min(top, vh - height - 12));
|
|
@@ -6470,7 +8418,7 @@ function unmount(state) {
|
|
|
6470
8418
|
* down the widget it was created for.
|
|
6471
8419
|
*/
|
|
6472
8420
|
function init(options) {
|
|
6473
|
-
return mount(options, { loadScreenshot: () => import("./screenshot-
|
|
8421
|
+
return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
|
|
6474
8422
|
}
|
|
6475
8423
|
//#endregion
|
|
6476
|
-
export { VERSION, init };
|
|
8424
|
+
export { VERSION, init, EN as messages };
|