@nuniapp/widget 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/index.d.ts +143 -1
- package/dist/index.js +2202 -319
- 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.5";
|
|
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\"/>")
|
|
@@ -5013,7 +5923,7 @@ const STYLES = `
|
|
|
5013
5923
|
-moz-osx-font-smoothing: grayscale;
|
|
5014
5924
|
}
|
|
5015
5925
|
@media (prefers-color-scheme: dark) {
|
|
5016
|
-
:host {
|
|
5926
|
+
:host(:not([data-theme="light"])) {
|
|
5017
5927
|
--n-bg: #1d1b20;
|
|
5018
5928
|
--n-bg-subtle: #2a272e;
|
|
5019
5929
|
--n-fg: #f4f2f6;
|
|
@@ -5024,6 +5934,18 @@ const STYLES = `
|
|
|
5024
5934
|
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
5025
5935
|
}
|
|
5026
5936
|
}
|
|
5937
|
+
:host([data-theme="dark"]) {
|
|
5938
|
+
--n-bg: #1d1b20;
|
|
5939
|
+
--n-bg-subtle: #2a272e;
|
|
5940
|
+
--n-fg: #f4f2f6;
|
|
5941
|
+
--n-fg-muted: #a8a2ae;
|
|
5942
|
+
--n-border: rgba(255, 255, 255, 0.12);
|
|
5943
|
+
--n-accent: #ff4d94;
|
|
5944
|
+
--n-accent-soft: rgba(255, 77, 148, 0.18);
|
|
5945
|
+
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
5946
|
+
}
|
|
5947
|
+
:host([data-theme="light"]) { color-scheme: light; }
|
|
5948
|
+
:host([data-theme="dark"]) { color-scheme: dark; }
|
|
5027
5949
|
*, *::before, *::after { box-sizing: border-box; }
|
|
5028
5950
|
button, input, textarea { font: inherit; color: inherit; }
|
|
5029
5951
|
button { cursor: pointer; }
|
|
@@ -5077,6 +5999,10 @@ button { cursor: pointer; }
|
|
|
5077
5999
|
|
|
5078
6000
|
/* Picking */
|
|
5079
6001
|
.pick-hint {
|
|
6002
|
+
display: flex;
|
|
6003
|
+
gap: 8px;
|
|
6004
|
+
align-items: center;
|
|
6005
|
+
max-width: calc(100vw - 24px);
|
|
5080
6006
|
position: fixed;
|
|
5081
6007
|
top: 12px;
|
|
5082
6008
|
left: 50%;
|
|
@@ -5113,6 +6039,94 @@ button { cursor: pointer; }
|
|
|
5113
6039
|
text-overflow: ellipsis;
|
|
5114
6040
|
}
|
|
5115
6041
|
|
|
6042
|
+
.drag-box { transition: none; border-style: dashed; }
|
|
6043
|
+
|
|
6044
|
+
/* Text and area comments */
|
|
6045
|
+
.marks { position: fixed; inset: 0; pointer-events: none; }
|
|
6046
|
+
.mark {
|
|
6047
|
+
position: fixed;
|
|
6048
|
+
border-bottom: 2px solid var(--n-accent);
|
|
6049
|
+
background: var(--n-accent-soft);
|
|
6050
|
+
opacity: 0.7;
|
|
6051
|
+
pointer-events: none;
|
|
6052
|
+
}
|
|
6053
|
+
.mark-active { opacity: 1; background: color-mix(in srgb, var(--n-accent) 26%, transparent); }
|
|
6054
|
+
.area {
|
|
6055
|
+
position: fixed;
|
|
6056
|
+
border: 2px dashed var(--n-accent);
|
|
6057
|
+
border-radius: 4px;
|
|
6058
|
+
background: var(--n-accent-soft);
|
|
6059
|
+
pointer-events: none;
|
|
6060
|
+
}
|
|
6061
|
+
.select-btn {
|
|
6062
|
+
position: fixed;
|
|
6063
|
+
left: 0;
|
|
6064
|
+
top: 0;
|
|
6065
|
+
display: inline-flex;
|
|
6066
|
+
align-items: center;
|
|
6067
|
+
gap: 6px;
|
|
6068
|
+
height: 32px;
|
|
6069
|
+
padding: 0 12px;
|
|
6070
|
+
border: 0;
|
|
6071
|
+
border-radius: 999px;
|
|
6072
|
+
background: var(--n-fg);
|
|
6073
|
+
color: var(--n-bg);
|
|
6074
|
+
font-weight: 550;
|
|
6075
|
+
font-size: 13px;
|
|
6076
|
+
box-shadow: var(--n-shadow);
|
|
6077
|
+
pointer-events: auto;
|
|
6078
|
+
}
|
|
6079
|
+
.select-btn[hidden] { display: none; }
|
|
6080
|
+
.quote {
|
|
6081
|
+
margin: 0;
|
|
6082
|
+
padding: 2px 0 2px 10px;
|
|
6083
|
+
border-left: 3px solid var(--n-accent);
|
|
6084
|
+
color: var(--n-fg-muted);
|
|
6085
|
+
font-style: italic;
|
|
6086
|
+
white-space: pre-wrap;
|
|
6087
|
+
overflow-wrap: anywhere;
|
|
6088
|
+
max-height: 4.5em;
|
|
6089
|
+
overflow: hidden;
|
|
6090
|
+
}
|
|
6091
|
+
.item-quote {
|
|
6092
|
+
margin-top: 4px;
|
|
6093
|
+
padding-left: 8px;
|
|
6094
|
+
border-left: 2px solid var(--n-accent);
|
|
6095
|
+
color: var(--n-fg-muted);
|
|
6096
|
+
font-size: 12px;
|
|
6097
|
+
font-style: italic;
|
|
6098
|
+
white-space: nowrap;
|
|
6099
|
+
overflow: hidden;
|
|
6100
|
+
text-overflow: ellipsis;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
.pick-keys { opacity: 0.7; }
|
|
6104
|
+
.pick-keys::before { content: "·"; margin-right: 8px; }
|
|
6105
|
+
@media (max-width: 900px) { .pick-keys { display: none; } }
|
|
6106
|
+
|
|
6107
|
+
/* Confirm dialog */
|
|
6108
|
+
.confirm-backdrop {
|
|
6109
|
+
position: fixed;
|
|
6110
|
+
inset: 0;
|
|
6111
|
+
display: grid;
|
|
6112
|
+
place-items: center;
|
|
6113
|
+
background: rgba(0, 0, 0, 0.32);
|
|
6114
|
+
pointer-events: auto;
|
|
6115
|
+
}
|
|
6116
|
+
.confirm {
|
|
6117
|
+
width: min(360px, calc(100vw - 32px));
|
|
6118
|
+
padding: 16px;
|
|
6119
|
+
border-radius: var(--n-radius);
|
|
6120
|
+
background: var(--n-bg);
|
|
6121
|
+
color: var(--n-fg);
|
|
6122
|
+
border: 1px solid var(--n-border);
|
|
6123
|
+
box-shadow: var(--n-shadow);
|
|
6124
|
+
display: grid;
|
|
6125
|
+
gap: 12px;
|
|
6126
|
+
}
|
|
6127
|
+
.confirm p { margin: 0; }
|
|
6128
|
+
.btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
|
|
6129
|
+
|
|
5116
6130
|
/* Pins */
|
|
5117
6131
|
.pin {
|
|
5118
6132
|
position: fixed;
|
|
@@ -5138,6 +6152,22 @@ button { cursor: pointer; }
|
|
|
5138
6152
|
.pin:hover, .pin[data-active="true"] { z-index: 2; }
|
|
5139
6153
|
.pin[data-active="true"] { box-shadow: 0 0 0 3px var(--n-accent), 0 2px 8px rgba(0,0,0,0.25); }
|
|
5140
6154
|
.pin[data-status="resolved"] { opacity: 0.55; filter: grayscale(0.6); }
|
|
6155
|
+
.pin[data-replies]::after {
|
|
6156
|
+
content: attr(data-replies);
|
|
6157
|
+
position: absolute;
|
|
6158
|
+
top: -6px;
|
|
6159
|
+
right: -8px;
|
|
6160
|
+
min-width: 16px;
|
|
6161
|
+
height: 16px;
|
|
6162
|
+
padding: 0 4px;
|
|
6163
|
+
border-radius: 999px;
|
|
6164
|
+
background: var(--n-bg);
|
|
6165
|
+
color: var(--n-fg);
|
|
6166
|
+
border: 1px solid var(--n-border);
|
|
6167
|
+
font-size: 10px;
|
|
6168
|
+
line-height: 14px;
|
|
6169
|
+
text-align: center;
|
|
6170
|
+
}
|
|
5141
6171
|
.pin[data-confidence="low"] { border-style: dashed; }
|
|
5142
6172
|
.pin[hidden] { display: none; }
|
|
5143
6173
|
.pin-pending { opacity: 0.7; }
|
|
@@ -5156,6 +6186,43 @@ button { cursor: pointer; }
|
|
|
5156
6186
|
overflow: hidden;
|
|
5157
6187
|
}
|
|
5158
6188
|
.card-body { padding: 12px; display: grid; gap: 10px; }
|
|
6189
|
+
.card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
|
|
6190
|
+
.thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
|
|
6191
|
+
.replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
|
|
6192
|
+
.reply { display: grid; gap: 6px; }
|
|
6193
|
+
.avatar-sm { width: 20px; height: 20px; font-size: 9px; }
|
|
6194
|
+
.badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
|
|
6195
|
+
.reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
|
|
6196
|
+
.reply-form .field { flex: 1 1 100%; }
|
|
6197
|
+
/* A fixed size: growing on focus would move the Send button mid-click. */
|
|
6198
|
+
.reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
|
|
6199
|
+
.reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
|
|
6200
|
+
.reaction {
|
|
6201
|
+
display: inline-flex;
|
|
6202
|
+
align-items: center;
|
|
6203
|
+
gap: 4px;
|
|
6204
|
+
height: 24px;
|
|
6205
|
+
padding: 0 8px;
|
|
6206
|
+
border: 1px solid var(--n-border);
|
|
6207
|
+
border-radius: 999px;
|
|
6208
|
+
background: var(--n-bg);
|
|
6209
|
+
color: var(--n-fg);
|
|
6210
|
+
font-size: 12px;
|
|
6211
|
+
}
|
|
6212
|
+
.reaction:hover { background: var(--n-bg-subtle); }
|
|
6213
|
+
.reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
|
|
6214
|
+
.reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
|
|
6215
|
+
.reaction-picker {
|
|
6216
|
+
display: flex;
|
|
6217
|
+
gap: 2px;
|
|
6218
|
+
padding: 4px;
|
|
6219
|
+
border: 1px solid var(--n-border);
|
|
6220
|
+
border-radius: 999px;
|
|
6221
|
+
background: var(--n-bg);
|
|
6222
|
+
box-shadow: var(--n-shadow);
|
|
6223
|
+
}
|
|
6224
|
+
.reaction-picker .reaction { border-color: transparent; font-size: 14px; }
|
|
6225
|
+
.btn-xs { width: 24px; height: 24px; }
|
|
5159
6226
|
.card-head { display: flex; align-items: center; gap: 8px; }
|
|
5160
6227
|
.card-head .spacer { flex: 1; }
|
|
5161
6228
|
.avatar {
|
|
@@ -5330,14 +6397,66 @@ a.item { color: inherit; text-decoration: none; }
|
|
|
5330
6397
|
@media (prefers-reduced-motion: reduce) {
|
|
5331
6398
|
* { transition: none !important; animation: none !important; }
|
|
5332
6399
|
}
|
|
6400
|
+
|
|
6401
|
+
/* The toolbar's corner (the position option) */
|
|
6402
|
+
:host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
|
|
6403
|
+
:host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
|
|
6404
|
+
:host([data-position$="left"]) .panel { right: auto; left: 12px; }
|
|
6405
|
+
:host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
|
|
6406
|
+
:host([data-position^="top"]) .toast { bottom: auto; top: 72px; }
|
|
6407
|
+
@media (max-width: 640px) {
|
|
6408
|
+
:host([data-position$="left"]) .toolbar { left: 12px; }
|
|
6409
|
+
:host([data-position^="top"]) .toolbar { top: 12px; }
|
|
6410
|
+
}
|
|
5333
6411
|
`;
|
|
5334
6412
|
//#endregion
|
|
5335
6413
|
//#region src/widget.ts
|
|
5336
6414
|
const PICKING_CLASS = "nuni-picking";
|
|
6415
|
+
const PICK_EVENTS = [
|
|
6416
|
+
"pointerdown",
|
|
6417
|
+
"mousedown",
|
|
6418
|
+
"pointerup",
|
|
6419
|
+
"mouseup"
|
|
6420
|
+
];
|
|
6421
|
+
/** How far the pointer moves before a press becomes an area drag. */
|
|
6422
|
+
const DRAG_THRESHOLD = 6;
|
|
6423
|
+
/** The rectangles of a range that have a size (lines of text). */
|
|
6424
|
+
function lineRects(range) {
|
|
6425
|
+
return Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
6426
|
+
}
|
|
6427
|
+
/** An area comment's box on screen, from its element's box. */
|
|
6428
|
+
function regionBox(anchor, el) {
|
|
6429
|
+
const region = anchor.region;
|
|
6430
|
+
if (!region) return null;
|
|
6431
|
+
const r = viewportRect(el, document);
|
|
6432
|
+
return {
|
|
6433
|
+
left: r.left + region.x * r.width,
|
|
6434
|
+
top: r.top + region.y * r.height,
|
|
6435
|
+
width: region.w * r.width,
|
|
6436
|
+
height: region.h * r.height
|
|
6437
|
+
};
|
|
6438
|
+
}
|
|
6439
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
6440
|
+
/** Run when the browser is idle, or soon. */
|
|
6441
|
+
function whenIdle(fn) {
|
|
6442
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(fn, { timeout: 100 });
|
|
6443
|
+
else setTimeout(fn, 0);
|
|
6444
|
+
}
|
|
6445
|
+
/** Same-origin iframe documents on the page, nested ones too. */
|
|
6446
|
+
function frameDocuments(doc, depth = 0) {
|
|
6447
|
+
if (depth > 3) return [];
|
|
6448
|
+
const out = [];
|
|
6449
|
+
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
|
6450
|
+
const inner = frameDocument(frame);
|
|
6451
|
+
if (inner?.documentElement) out.push(inner, ...frameDocuments(inner, depth + 1));
|
|
6452
|
+
}
|
|
6453
|
+
return out;
|
|
6454
|
+
}
|
|
5337
6455
|
const MOBILE_QUERY = "(max-width: 640px)";
|
|
5338
6456
|
function isTypingTarget(target) {
|
|
5339
|
-
if (!
|
|
5340
|
-
|
|
6457
|
+
if (!target || target.nodeType !== 1) return false;
|
|
6458
|
+
const el = target;
|
|
6459
|
+
return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
|
5341
6460
|
}
|
|
5342
6461
|
function dataUrl(blob) {
|
|
5343
6462
|
return new Promise((resolve, reject) => {
|
|
@@ -5360,8 +6479,10 @@ var NuniWidget = class {
|
|
|
5360
6479
|
this.runtime = runtime;
|
|
5361
6480
|
_defineProperty(this, "host", void 0);
|
|
5362
6481
|
_defineProperty(this, "root", void 0);
|
|
6482
|
+
_defineProperty(this, "markLayer", void 0);
|
|
5363
6483
|
_defineProperty(this, "pinLayer", void 0);
|
|
5364
6484
|
_defineProperty(this, "overlayLayer", void 0);
|
|
6485
|
+
_defineProperty(this, "selectButton", void 0);
|
|
5365
6486
|
_defineProperty(this, "uiLayer", void 0);
|
|
5366
6487
|
_defineProperty(this, "api", void 0);
|
|
5367
6488
|
_defineProperty(this, "cleanups", []);
|
|
@@ -5393,10 +6514,28 @@ var NuniWidget = class {
|
|
|
5393
6514
|
_defineProperty(this, "ownerDetailId", null);
|
|
5394
6515
|
_defineProperty(this, "ownerDetailLoaded", false);
|
|
5395
6516
|
_defineProperty(this, "ownerDetailUnsub", null);
|
|
6517
|
+
_defineProperty(this, "thread", null);
|
|
6518
|
+
_defineProperty(this, "threadId", null);
|
|
6519
|
+
_defineProperty(this, "threadUnsub", null);
|
|
6520
|
+
_defineProperty(this, "resolveRun", 0);
|
|
6521
|
+
_defineProperty(this, "scopeWatchers", /* @__PURE__ */ new Map());
|
|
6522
|
+
_defineProperty(this, "pickDocs", []);
|
|
6523
|
+
_defineProperty(this, "dragStart", null);
|
|
6524
|
+
_defineProperty(this, "dragBox", null);
|
|
6525
|
+
_defineProperty(this, "dragEnded", false);
|
|
6526
|
+
_defineProperty(this, "selection", null);
|
|
6527
|
+
_defineProperty(this, "pointerDown", false);
|
|
6528
|
+
_defineProperty(this, "selectionFrame", 0);
|
|
6529
|
+
_defineProperty(this, "returnFocus", null);
|
|
6530
|
+
_defineProperty(this, "focusNext", null);
|
|
6531
|
+
_defineProperty(this, "confirming", null);
|
|
6532
|
+
_defineProperty(this, "live", void 0);
|
|
5396
6533
|
_defineProperty(this, "layoutFrame", 0);
|
|
5397
6534
|
_defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
|
|
5398
6535
|
_defineProperty(this, "resolveTimer", 0);
|
|
6536
|
+
_defineProperty(this, "i18n", void 0);
|
|
5399
6537
|
_defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
|
|
6538
|
+
_defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
|
|
5400
6539
|
_defineProperty(this, "swallow", (e) => {
|
|
5401
6540
|
if (!this.fromPage(e)) return;
|
|
5402
6541
|
if (e.type === "pointerdown" && e instanceof PointerEvent) {
|
|
@@ -5405,11 +6544,41 @@ var NuniWidget = class {
|
|
|
5405
6544
|
this.hoverEl = pickTarget(target);
|
|
5406
6545
|
this.renderHighlight();
|
|
5407
6546
|
}
|
|
6547
|
+
this.dragEnded = false;
|
|
6548
|
+
this.dragStart = e.pointerType !== "touch" && e.button === 0 && (target?.ownerDocument ?? null) === document ? {
|
|
6549
|
+
x: e.clientX,
|
|
6550
|
+
y: e.clientY
|
|
6551
|
+
} : null;
|
|
5408
6552
|
}
|
|
6553
|
+
if (e.type === "pointerup" && this.dragBox) {
|
|
6554
|
+
const box = this.dragBox;
|
|
6555
|
+
this.dragStart = null;
|
|
6556
|
+
this.dragBox = null;
|
|
6557
|
+
this.dragEnded = true;
|
|
6558
|
+
setTimeout(() => {
|
|
6559
|
+
this.dragEnded = false;
|
|
6560
|
+
if (this.picking) this.commentOnArea(box);
|
|
6561
|
+
}, 0);
|
|
6562
|
+
} else if (e.type === "pointerup") this.dragStart = null;
|
|
5409
6563
|
e.preventDefault();
|
|
5410
6564
|
e.stopPropagation();
|
|
5411
6565
|
});
|
|
5412
6566
|
_defineProperty(this, "onPickMove", (e) => {
|
|
6567
|
+
const start = this.dragStart;
|
|
6568
|
+
if (start) {
|
|
6569
|
+
const dx = e.clientX - start.x;
|
|
6570
|
+
const dy = e.clientY - start.y;
|
|
6571
|
+
if (this.dragBox || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
6572
|
+
this.dragBox = {
|
|
6573
|
+
left: Math.min(start.x, e.clientX),
|
|
6574
|
+
top: Math.min(start.y, e.clientY),
|
|
6575
|
+
width: Math.abs(dx),
|
|
6576
|
+
height: Math.abs(dy)
|
|
6577
|
+
};
|
|
6578
|
+
this.renderHighlight();
|
|
6579
|
+
return;
|
|
6580
|
+
}
|
|
6581
|
+
}
|
|
5413
6582
|
const target = this.fromPage(e);
|
|
5414
6583
|
const next = target ? pickTarget(target) : null;
|
|
5415
6584
|
if (next === this.hoverEl) return;
|
|
@@ -5422,27 +6591,21 @@ var NuniWidget = class {
|
|
|
5422
6591
|
e.preventDefault();
|
|
5423
6592
|
e.stopPropagation();
|
|
5424
6593
|
e.stopImmediatePropagation();
|
|
6594
|
+
if (this.dragEnded) return;
|
|
5425
6595
|
const element = pickTarget(target);
|
|
5426
6596
|
const anchor = captureAnchor(element, {
|
|
5427
6597
|
x: e.clientX,
|
|
5428
6598
|
y: e.clientY
|
|
5429
|
-
}, {
|
|
5430
|
-
|
|
5431
|
-
|
|
6599
|
+
}, {
|
|
6600
|
+
ignoreAttributePrefixes: [],
|
|
6601
|
+
document
|
|
6602
|
+
});
|
|
6603
|
+
this.openComposer({
|
|
5432
6604
|
anchor,
|
|
5433
6605
|
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);
|
|
6606
|
+
});
|
|
5445
6607
|
});
|
|
6608
|
+
this.i18n = createI18n(config.locale, config.messages);
|
|
5446
6609
|
let secret = read(KEYS.secret);
|
|
5447
6610
|
if (!secret) {
|
|
5448
6611
|
secret = generateSecret();
|
|
@@ -5453,15 +6616,56 @@ var NuniWidget = class {
|
|
|
5453
6616
|
this.host = document.createElement("div");
|
|
5454
6617
|
this.host.id = "nuni-root";
|
|
5455
6618
|
this.host.setAttribute("data-nuni", "");
|
|
6619
|
+
this.applyAppearance();
|
|
5456
6620
|
this.root = this.host.attachShadow({ mode: "open" });
|
|
5457
6621
|
const style = document.createElement("style");
|
|
5458
6622
|
style.textContent = STYLES;
|
|
6623
|
+
this.markLayer = h("div", {
|
|
6624
|
+
class: "marks",
|
|
6625
|
+
"aria-hidden": "true"
|
|
6626
|
+
});
|
|
5459
6627
|
this.pinLayer = h("div", { class: "pins" });
|
|
5460
6628
|
this.overlayLayer = h("div", { class: "overlay" });
|
|
5461
6629
|
this.uiLayer = h("div", { class: "ui" });
|
|
5462
|
-
this.
|
|
6630
|
+
this.selectButton = h("button", {
|
|
6631
|
+
class: "select-btn",
|
|
6632
|
+
type: "button",
|
|
6633
|
+
hidden: true,
|
|
6634
|
+
title: this.withKey("commentSelection", "commentSelectionKey"),
|
|
6635
|
+
onmousedown: (e) => e.preventDefault(),
|
|
6636
|
+
onpointerdown: (e) => e.preventDefault(),
|
|
6637
|
+
onclick: () => this.commentOnSelection()
|
|
6638
|
+
}, icon(ICONS.comment), this.t("comment"));
|
|
6639
|
+
this.live = h("div", {
|
|
6640
|
+
class: "sr",
|
|
6641
|
+
role: "status",
|
|
6642
|
+
"aria-live": "polite"
|
|
6643
|
+
});
|
|
6644
|
+
this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
|
|
6645
|
+
this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
|
|
5463
6646
|
this.api = new NuniApi(config);
|
|
5464
6647
|
}
|
|
6648
|
+
t(key, vars) {
|
|
6649
|
+
return this.i18n.t(key, vars);
|
|
6650
|
+
}
|
|
6651
|
+
/** A message that names the hotkey, or the plain one when it is off. */
|
|
6652
|
+
withKey(plain, keyed) {
|
|
6653
|
+
const key = this.config.hotkey;
|
|
6654
|
+
return key ? this.t(keyed, { key: key.toUpperCase() }) : this.t(plain);
|
|
6655
|
+
}
|
|
6656
|
+
/** Position, theme, brand color and stacking order from the options. */
|
|
6657
|
+
applyAppearance() {
|
|
6658
|
+
const { position, theme, accentColor, zIndex } = this.config;
|
|
6659
|
+
this.host.dataset.position = position;
|
|
6660
|
+
this.host.dataset.theme = theme;
|
|
6661
|
+
if (zIndex !== null) this.host.style.zIndex = String(zIndex);
|
|
6662
|
+
const rgb = accentColor ? parseColor(accentColor) : null;
|
|
6663
|
+
if (!rgb) return;
|
|
6664
|
+
const [r, g, b] = rgb;
|
|
6665
|
+
this.host.style.setProperty("--n-accent", `rgb(${r}, ${g}, ${b})`);
|
|
6666
|
+
this.host.style.setProperty("--n-accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`);
|
|
6667
|
+
this.host.style.setProperty("--n-accent-fg", readableOn(rgb));
|
|
6668
|
+
}
|
|
5465
6669
|
start() {
|
|
5466
6670
|
document.body.appendChild(this.host);
|
|
5467
6671
|
sha256Hex(this.secret).then((hash) => {
|
|
@@ -5494,6 +6698,25 @@ var NuniWidget = class {
|
|
|
5494
6698
|
});
|
|
5495
6699
|
window.addEventListener("resize", onScroll, { passive: true });
|
|
5496
6700
|
window.addEventListener("message", onMessage);
|
|
6701
|
+
const onSelection = () => this.scheduleSelection();
|
|
6702
|
+
const onDown = (e) => {
|
|
6703
|
+
if (e.composedPath().includes(this.host)) return;
|
|
6704
|
+
this.pointerDown = true;
|
|
6705
|
+
this.scheduleSelection();
|
|
6706
|
+
};
|
|
6707
|
+
const onUp = () => {
|
|
6708
|
+
this.pointerDown = false;
|
|
6709
|
+
this.scheduleSelection();
|
|
6710
|
+
};
|
|
6711
|
+
document.addEventListener("selectionchange", onSelection);
|
|
6712
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
6713
|
+
document.addEventListener("pointerup", onUp, true);
|
|
6714
|
+
this.cleanups.push(() => {
|
|
6715
|
+
document.removeEventListener("selectionchange", onSelection);
|
|
6716
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
6717
|
+
document.removeEventListener("pointerup", onUp, true);
|
|
6718
|
+
cancelAnimationFrame(this.selectionFrame);
|
|
6719
|
+
});
|
|
5497
6720
|
this.cleanups.push(() => {
|
|
5498
6721
|
window.removeEventListener("keydown", onKey, true);
|
|
5499
6722
|
window.removeEventListener("scroll", onScroll, true);
|
|
@@ -5530,6 +6753,10 @@ var NuniWidget = class {
|
|
|
5530
6753
|
this.pageUnsub?.();
|
|
5531
6754
|
this.sessionUnsub?.();
|
|
5532
6755
|
this.ownerDetailUnsub?.();
|
|
6756
|
+
this.threadUnsub?.();
|
|
6757
|
+
this.resolveRun++;
|
|
6758
|
+
for (const stop of this.scopeWatchers.values()) stop();
|
|
6759
|
+
this.scopeWatchers.clear();
|
|
5533
6760
|
for (const cleanup of this.cleanups) cleanup();
|
|
5534
6761
|
cancelAnimationFrame(this.layoutFrame);
|
|
5535
6762
|
clearTimeout(this.resolveTimer);
|
|
@@ -5539,7 +6766,7 @@ var NuniWidget = class {
|
|
|
5539
6766
|
}
|
|
5540
6767
|
currentPageKey() {
|
|
5541
6768
|
const url = new URL(location.href);
|
|
5542
|
-
return this.config.getPageKey
|
|
6769
|
+
return this.config.getPageKey(url);
|
|
5543
6770
|
}
|
|
5544
6771
|
subscribePage() {
|
|
5545
6772
|
const key = this.currentPageKey();
|
|
@@ -5557,7 +6784,7 @@ var NuniWidget = class {
|
|
|
5557
6784
|
const merged = extra && !comments.some((c) => c._id === extra._id) ? [...comments, extra] : comments;
|
|
5558
6785
|
this.comments = merged.sort((a, b) => a.createdAt - b.createdAt);
|
|
5559
6786
|
this.loaded = true;
|
|
5560
|
-
this.resolvePlacements(
|
|
6787
|
+
this.resolvePlacements(this.placements.size > 0, this.deepLinkId ?? void 0);
|
|
5561
6788
|
this.openDeepLink();
|
|
5562
6789
|
this.render();
|
|
5563
6790
|
}, () => {});
|
|
@@ -5566,7 +6793,7 @@ var NuniWidget = class {
|
|
|
5566
6793
|
watchSession(token) {
|
|
5567
6794
|
this.sessionUnsub?.();
|
|
5568
6795
|
this.sessionUnsub = this.api.onSession(token, (result) => {
|
|
5569
|
-
if (result.valid) this.ownerName = result.ownerName ?? "
|
|
6796
|
+
if (result.valid) this.ownerName = result.ownerName ?? this.t("owner");
|
|
5570
6797
|
else {
|
|
5571
6798
|
this.ownerName = null;
|
|
5572
6799
|
this.ownerToken = null;
|
|
@@ -5595,6 +6822,21 @@ var NuniWidget = class {
|
|
|
5595
6822
|
this.render();
|
|
5596
6823
|
});
|
|
5597
6824
|
}
|
|
6825
|
+
/** Keep the open thread's replies and reactions subscribed. */
|
|
6826
|
+
syncThread() {
|
|
6827
|
+
const id = this.card?.kind === "thread" ? this.card.id : null;
|
|
6828
|
+
if (id === this.threadId) return;
|
|
6829
|
+
this.threadUnsub?.();
|
|
6830
|
+
this.threadUnsub = null;
|
|
6831
|
+
this.thread = null;
|
|
6832
|
+
this.threadId = id;
|
|
6833
|
+
if (!id) return;
|
|
6834
|
+
this.threadUnsub = this.api.onThread(id, (thread) => {
|
|
6835
|
+
if (this.threadId !== id) return;
|
|
6836
|
+
this.thread = thread;
|
|
6837
|
+
this.render();
|
|
6838
|
+
});
|
|
6839
|
+
}
|
|
5598
6840
|
get isOwner() {
|
|
5599
6841
|
return Boolean(this.ownerToken && this.ownerName);
|
|
5600
6842
|
}
|
|
@@ -5608,38 +6850,165 @@ var NuniWidget = class {
|
|
|
5608
6850
|
clearTimeout(this.resolveTimer);
|
|
5609
6851
|
this.resolveTimer = window.setTimeout(() => this.resolvePlacements(true), 200);
|
|
5610
6852
|
}
|
|
6853
|
+
stillPlaced(comment) {
|
|
6854
|
+
const current = this.placements.get(comment._id);
|
|
6855
|
+
if (!current?.element?.isConnected) return false;
|
|
6856
|
+
const quote = comment.anchor.quote;
|
|
6857
|
+
if (quote) return current.range !== void 0 && collapse(current.range.toString()) === quote.exact;
|
|
6858
|
+
const text = elementText(current.element);
|
|
6859
|
+
const expected = comment.anchor.text;
|
|
6860
|
+
return !expected || text === expected || textSimilarity(text, expected) > .8;
|
|
6861
|
+
}
|
|
5611
6862
|
/**
|
|
5612
6863
|
* Find each comment's element. With `onlyStale`, keep placements whose
|
|
5613
6864
|
* element is still attached and still looks like what was captured.
|
|
6865
|
+
* Works in slices of about 8 ms, so a page with many pins stays
|
|
6866
|
+
* responsive; `first` (a deep-linked comment) is found right away.
|
|
5614
6867
|
*/
|
|
5615
|
-
resolvePlacements(onlyStale) {
|
|
6868
|
+
resolvePlacements(onlyStale, first) {
|
|
6869
|
+
const run = ++this.resolveRun;
|
|
5616
6870
|
const ids = new Set(this.comments.map((c) => c._id));
|
|
5617
6871
|
for (const id of this.placements.keys()) if (!ids.has(id)) this.placements.delete(id);
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
6872
|
+
const queue = this.comments.filter((c) => !(onlyStale && this.stillPlaced(c)));
|
|
6873
|
+
const index = first ? queue.findIndex((c) => c._id === first) : -1;
|
|
6874
|
+
if (index > 0) queue.unshift(...queue.splice(index, 1));
|
|
6875
|
+
const cache = createResolveCache();
|
|
6876
|
+
const options = {
|
|
6877
|
+
isIgnored: this.isIgnored,
|
|
6878
|
+
document
|
|
6879
|
+
};
|
|
6880
|
+
const slice = () => {
|
|
6881
|
+
if (run !== this.resolveRun) return;
|
|
6882
|
+
const started = performance.now();
|
|
6883
|
+
while (queue.length) {
|
|
6884
|
+
const comment = queue.shift();
|
|
6885
|
+
const result = resolveAnchor(comment.anchor, document, options, cache);
|
|
6886
|
+
this.placements.set(comment._id, {
|
|
6887
|
+
element: result.element,
|
|
6888
|
+
confidence: result.confidence,
|
|
6889
|
+
range: result.range
|
|
6890
|
+
});
|
|
6891
|
+
if (performance.now() - started > 8) break;
|
|
6892
|
+
}
|
|
6893
|
+
this.watchScopes();
|
|
6894
|
+
this.renderPins();
|
|
6895
|
+
this.layout();
|
|
6896
|
+
if (queue.length) whenIdle(slice);
|
|
6897
|
+
else if ((onlyStale || run > 1) && this.panelOpen) this.render();
|
|
6898
|
+
};
|
|
6899
|
+
slice();
|
|
6900
|
+
}
|
|
6901
|
+
/**
|
|
6902
|
+
* Pins inside shadow roots and iframes: the page's observers don't see
|
|
6903
|
+
* changes or scrolling in there, so watch each of those scopes too.
|
|
6904
|
+
*/
|
|
6905
|
+
watchScopes() {
|
|
6906
|
+
const roots = /* @__PURE__ */ new Set();
|
|
6907
|
+
for (const { element } of this.placements.values()) {
|
|
6908
|
+
let node = element;
|
|
6909
|
+
for (let depth = 0; node && depth < 8; depth++) {
|
|
6910
|
+
const root = scopeRootOf(node);
|
|
6911
|
+
if (root === document) break;
|
|
6912
|
+
roots.add(root);
|
|
6913
|
+
node = isShadowRoot(root) ? root.host : frameElementOf(root);
|
|
5624
6914
|
}
|
|
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
6915
|
}
|
|
5631
|
-
this.
|
|
5632
|
-
|
|
5633
|
-
|
|
6916
|
+
for (const [root, stop] of this.scopeWatchers) if (!roots.has(root)) {
|
|
6917
|
+
stop();
|
|
6918
|
+
this.scopeWatchers.delete(root);
|
|
6919
|
+
}
|
|
6920
|
+
for (const root of roots) if (!this.scopeWatchers.has(root)) this.scopeWatchers.set(root, this.watchScope(root));
|
|
6921
|
+
}
|
|
6922
|
+
watchScope(root) {
|
|
6923
|
+
const observer = new MutationObserver(() => this.scheduleResolve());
|
|
6924
|
+
const doc = isShadowRoot(root) ? null : root;
|
|
6925
|
+
observer.observe(doc ? doc.body ?? doc.documentElement : root, {
|
|
6926
|
+
childList: true,
|
|
6927
|
+
subtree: true,
|
|
6928
|
+
characterData: true,
|
|
6929
|
+
attributes: true,
|
|
6930
|
+
attributeFilter: [
|
|
6931
|
+
"class",
|
|
6932
|
+
"style",
|
|
6933
|
+
"hidden",
|
|
6934
|
+
"open",
|
|
6935
|
+
"id"
|
|
6936
|
+
]
|
|
6937
|
+
});
|
|
6938
|
+
const onScroll = () => this.scheduleLayout();
|
|
6939
|
+
const target = doc?.defaultView ?? root;
|
|
6940
|
+
target.addEventListener("scroll", onScroll, {
|
|
6941
|
+
capture: true,
|
|
6942
|
+
passive: true
|
|
6943
|
+
});
|
|
6944
|
+
doc?.defaultView?.addEventListener("resize", onScroll, { passive: true });
|
|
6945
|
+
return () => {
|
|
6946
|
+
observer.disconnect();
|
|
6947
|
+
target.removeEventListener("scroll", onScroll, true);
|
|
6948
|
+
doc?.defaultView?.removeEventListener("resize", onScroll);
|
|
6949
|
+
};
|
|
5634
6950
|
}
|
|
5635
6951
|
pinPoint(comment) {
|
|
5636
|
-
const
|
|
6952
|
+
const placement = this.placements.get(comment._id);
|
|
6953
|
+
const el = placement?.element;
|
|
5637
6954
|
if (!el || !isRendered(el)) return null;
|
|
5638
|
-
|
|
6955
|
+
if (viewportRect(el, document).clipped) return null;
|
|
6956
|
+
return this.anchorPoint(comment.anchor, el, placement.range);
|
|
6957
|
+
}
|
|
6958
|
+
/**
|
|
6959
|
+
* Where a pin goes: after the last selected word for a text comment, the
|
|
6960
|
+
* top-right corner of an area, or where the element was clicked.
|
|
6961
|
+
*/
|
|
6962
|
+
anchorPoint(anchor, el, range) {
|
|
6963
|
+
if (range && range.startContainer.ownerDocument === document) {
|
|
6964
|
+
const last = lineRects(range).at(-1);
|
|
6965
|
+
if (last) return {
|
|
6966
|
+
x: last.right,
|
|
6967
|
+
y: last.top + last.height / 2
|
|
6968
|
+
};
|
|
6969
|
+
}
|
|
6970
|
+
const r = viewportRect(el, document);
|
|
5639
6971
|
return {
|
|
5640
|
-
x: r.left +
|
|
5641
|
-
y: r.top +
|
|
6972
|
+
x: r.left + anchor.offset.x * r.width,
|
|
6973
|
+
y: r.top + anchor.offset.y * r.height
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
6976
|
+
/**
|
|
6977
|
+
* Text comments underline their words, and the open comment (or the one
|
|
6978
|
+
* being written) also shows its words or its area strongly.
|
|
6979
|
+
*/
|
|
6980
|
+
renderMarks() {
|
|
6981
|
+
const marks = [];
|
|
6982
|
+
const add = (box, cls) => {
|
|
6983
|
+
const mark = h("div", { class: cls });
|
|
6984
|
+
Object.assign(mark.style, {
|
|
6985
|
+
left: `${box.left}px`,
|
|
6986
|
+
top: `${box.top}px`,
|
|
6987
|
+
width: `${box.width}px`,
|
|
6988
|
+
height: `${box.height}px`
|
|
6989
|
+
});
|
|
6990
|
+
marks.push(mark);
|
|
6991
|
+
};
|
|
6992
|
+
const show = (anchor, el, range, active) => {
|
|
6993
|
+
if (!el) return;
|
|
6994
|
+
if (range && range.startContainer.ownerDocument === document) for (const r of lineRects(range)) add(r, active ? "mark mark-active" : "mark");
|
|
6995
|
+
if (active) {
|
|
6996
|
+
const box = regionBox(anchor, el);
|
|
6997
|
+
if (box) add(box, "area");
|
|
6998
|
+
}
|
|
5642
6999
|
};
|
|
7000
|
+
if (this.card?.kind === "composer") {
|
|
7001
|
+
const { draft } = this.card;
|
|
7002
|
+
show(draft.anchor, draft.element, draft.range, true);
|
|
7003
|
+
}
|
|
7004
|
+
for (const comment of this.visibleComments()) {
|
|
7005
|
+
const placement = this.placements.get(comment._id);
|
|
7006
|
+
const active = this.activeId === comment._id;
|
|
7007
|
+
if (!active && (comment.status !== "open" || !comment.anchor.quote)) continue;
|
|
7008
|
+
if (this.pins.get(comment._id)?.hidden) continue;
|
|
7009
|
+
show(comment.anchor, placement?.element, placement?.range, active);
|
|
7010
|
+
}
|
|
7011
|
+
this.markLayer.replaceChildren(...marks);
|
|
5643
7012
|
}
|
|
5644
7013
|
scheduleLayout() {
|
|
5645
7014
|
if (this.layoutFrame) return;
|
|
@@ -5672,67 +7041,168 @@ var NuniWidget = class {
|
|
|
5672
7041
|
});
|
|
5673
7042
|
pin.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
5674
7043
|
}
|
|
7044
|
+
this.renderMarks();
|
|
5675
7045
|
this.positionCard();
|
|
5676
|
-
if (this.picking && this.hoverEl) this.renderHighlight();
|
|
7046
|
+
if (this.picking && (this.hoverEl || this.dragBox)) this.renderHighlight();
|
|
7047
|
+
this.placeSelectButton();
|
|
5677
7048
|
}
|
|
5678
7049
|
setPicking(on) {
|
|
5679
7050
|
if (this.picking === on) return;
|
|
5680
7051
|
this.picking = on;
|
|
5681
7052
|
const html = document.documentElement;
|
|
7053
|
+
this.dragStart = null;
|
|
7054
|
+
this.dragBox = null;
|
|
5682
7055
|
if (on) {
|
|
5683
7056
|
this.card = null;
|
|
5684
7057
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5685
|
-
|
|
5686
|
-
this.
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
"
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
});
|
|
7058
|
+
this.pickDocs = [document, ...frameDocuments(document)];
|
|
7059
|
+
for (const doc of this.pickDocs) {
|
|
7060
|
+
doc.documentElement.classList.add(PICKING_CLASS);
|
|
7061
|
+
this.injectCursorStyle(doc);
|
|
7062
|
+
doc.addEventListener("pointermove", this.onPickMove, true);
|
|
7063
|
+
doc.addEventListener("click", this.onPickClick, true);
|
|
7064
|
+
for (const type of PICK_EVENTS) doc.addEventListener(type, this.swallow, {
|
|
7065
|
+
capture: true,
|
|
7066
|
+
passive: false
|
|
7067
|
+
});
|
|
7068
|
+
if (doc !== document) doc.addEventListener("keydown", this.onFrameKey, true);
|
|
7069
|
+
}
|
|
5698
7070
|
} else {
|
|
5699
7071
|
html.classList.remove(PICKING_CLASS);
|
|
5700
7072
|
this.hoverEl = null;
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
"
|
|
5705
|
-
|
|
5706
|
-
"
|
|
5707
|
-
|
|
5708
|
-
|
|
7073
|
+
for (const doc of this.pickDocs) {
|
|
7074
|
+
doc.documentElement?.classList.remove(PICKING_CLASS);
|
|
7075
|
+
doc.removeEventListener("pointermove", this.onPickMove, true);
|
|
7076
|
+
doc.removeEventListener("click", this.onPickClick, true);
|
|
7077
|
+
for (const type of PICK_EVENTS) doc.removeEventListener(type, this.swallow, true);
|
|
7078
|
+
doc.removeEventListener("keydown", this.onFrameKey, true);
|
|
7079
|
+
}
|
|
7080
|
+
this.pickDocs = [];
|
|
5709
7081
|
}
|
|
5710
7082
|
this.render();
|
|
5711
7083
|
}
|
|
5712
|
-
injectCursorStyle() {
|
|
5713
|
-
if (
|
|
5714
|
-
const style =
|
|
7084
|
+
injectCursorStyle(doc) {
|
|
7085
|
+
if (doc.getElementById("nuni-cursor-style")) return;
|
|
7086
|
+
const style = doc.createElement("style");
|
|
5715
7087
|
style.id = "nuni-cursor-style";
|
|
5716
7088
|
style.textContent = `html.${PICKING_CLASS}, html.${PICKING_CLASS} * { cursor: crosshair !important; }`;
|
|
5717
|
-
|
|
7089
|
+
(doc.head ?? doc.documentElement).appendChild(style);
|
|
5718
7090
|
this.cleanups.push(() => style.remove());
|
|
5719
7091
|
}
|
|
7092
|
+
/**
|
|
7093
|
+
* The element the event happened on, inside open shadow roots too
|
|
7094
|
+
* (composedPath), and never Nuni's own UI.
|
|
7095
|
+
*/
|
|
5720
7096
|
fromPage(e) {
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
7097
|
+
const path = e.composedPath();
|
|
7098
|
+
if (path.includes(this.host)) return null;
|
|
7099
|
+
const target = path[0] ?? e.target;
|
|
7100
|
+
return target && target.nodeType === 1 ? target : null;
|
|
7101
|
+
}
|
|
7102
|
+
/** An area drawn while picking: anchored to the element that covers it. */
|
|
7103
|
+
commentOnArea(box) {
|
|
7104
|
+
const element = areaContainer(document, box, this.isIgnored);
|
|
7105
|
+
const anchor = captureArea(element, box, {
|
|
7106
|
+
ignoreAttributePrefixes: [],
|
|
7107
|
+
document
|
|
7108
|
+
});
|
|
7109
|
+
this.openComposer({
|
|
7110
|
+
anchor,
|
|
7111
|
+
element
|
|
7112
|
+
});
|
|
7113
|
+
}
|
|
7114
|
+
/** The page's selected text, if it can take a text comment. */
|
|
7115
|
+
pageSelection() {
|
|
7116
|
+
const selection = document.getSelection();
|
|
7117
|
+
if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
|
|
7118
|
+
const range = selection.getRangeAt(0);
|
|
7119
|
+
const node = range.commonAncestorContainer;
|
|
7120
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7121
|
+
if (!el || el === this.host || this.host.contains(el)) return null;
|
|
7122
|
+
if (el.closest("input, textarea, select, [contenteditable]")) return null;
|
|
7123
|
+
if (el.closest("[data-nuni]")) return null;
|
|
7124
|
+
return collapse(range.toString()) ? range : null;
|
|
7125
|
+
}
|
|
7126
|
+
commentOnSelection() {
|
|
7127
|
+
const range = this.pageSelection();
|
|
7128
|
+
if (!range) return;
|
|
7129
|
+
const captured = captureSelection(range, {
|
|
7130
|
+
ignoreAttributePrefixes: [],
|
|
7131
|
+
document
|
|
7132
|
+
});
|
|
7133
|
+
if (!captured) return;
|
|
7134
|
+
const kept = range.cloneRange();
|
|
7135
|
+
document.getSelection()?.removeAllRanges();
|
|
7136
|
+
this.selection = null;
|
|
7137
|
+
this.openComposer({
|
|
7138
|
+
...captured,
|
|
7139
|
+
range: kept
|
|
7140
|
+
});
|
|
7141
|
+
}
|
|
7142
|
+
scheduleSelection() {
|
|
7143
|
+
if (this.selectionFrame) return;
|
|
7144
|
+
this.selectionFrame = requestAnimationFrame(() => {
|
|
7145
|
+
this.selectionFrame = 0;
|
|
7146
|
+
this.selection = this.picking || this.pointerDown ? null : this.pageSelection();
|
|
7147
|
+
this.placeSelectButton();
|
|
7148
|
+
});
|
|
7149
|
+
}
|
|
7150
|
+
/** A small "Comment" button under the selected text. */
|
|
7151
|
+
placeSelectButton() {
|
|
7152
|
+
const button = this.selectButton;
|
|
7153
|
+
const range = this.selection;
|
|
7154
|
+
const last = range ? lineRects(range).at(-1) : void 0;
|
|
7155
|
+
const vw = window.innerWidth;
|
|
7156
|
+
const vh = window.innerHeight;
|
|
7157
|
+
if (!range || !last || last.bottom < 0 || last.top > vh) {
|
|
7158
|
+
button.hidden = true;
|
|
7159
|
+
return;
|
|
7160
|
+
}
|
|
7161
|
+
button.hidden = false;
|
|
7162
|
+
const width = button.offsetWidth || 96;
|
|
7163
|
+
const height = button.offsetHeight || 32;
|
|
7164
|
+
let top = last.bottom + 8;
|
|
7165
|
+
if (top + height > vh - 8) top = last.top - height - 8;
|
|
7166
|
+
const left = Math.max(8, Math.min(last.right - width / 2, vw - width - 8));
|
|
7167
|
+
button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
7168
|
+
}
|
|
7169
|
+
openComposer(draft) {
|
|
7170
|
+
const { element } = draft;
|
|
7171
|
+
this.setPicking(false);
|
|
7172
|
+
if (this.config.capture.dom) try {
|
|
7173
|
+
draft.dom = domContext(element);
|
|
7174
|
+
} catch {}
|
|
7175
|
+
this.card = {
|
|
7176
|
+
kind: "composer",
|
|
7177
|
+
draft
|
|
7178
|
+
};
|
|
7179
|
+
this.activeId = null;
|
|
7180
|
+
this.render();
|
|
7181
|
+
this.focusComposer();
|
|
7182
|
+
this.takeScreenshot(draft);
|
|
5724
7183
|
}
|
|
5725
7184
|
/** Capture while the person types, so posting stays instant. */
|
|
5726
7185
|
async takeScreenshot(draft) {
|
|
5727
7186
|
const load = this.runtime.loadScreenshot;
|
|
5728
|
-
if (!load) return;
|
|
7187
|
+
if (!load || draft.element.ownerDocument !== document) return;
|
|
5729
7188
|
const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
|
|
5730
7189
|
try {
|
|
5731
7190
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
5732
7191
|
const capture = await load();
|
|
5733
|
-
const
|
|
7192
|
+
const { range, anchor, element } = draft;
|
|
7193
|
+
const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
|
|
7194
|
+
const box = regionBox(anchor, element);
|
|
7195
|
+
return {
|
|
7196
|
+
left: box.left,
|
|
7197
|
+
top: box.top,
|
|
7198
|
+
right: box.left + box.width,
|
|
7199
|
+
bottom: box.top + box.height
|
|
7200
|
+
};
|
|
7201
|
+
} : void 0;
|
|
7202
|
+
const blob = capture ? await capture(element, {
|
|
5734
7203
|
exclude: this.host,
|
|
5735
|
-
accent
|
|
7204
|
+
accent,
|
|
7205
|
+
focus
|
|
5736
7206
|
}) : null;
|
|
5737
7207
|
if (!blob || !this.isComposing(draft) || draft.sending) return;
|
|
5738
7208
|
const preview = await dataUrl(blob);
|
|
@@ -5764,15 +7234,31 @@ var NuniWidget = class {
|
|
|
5764
7234
|
}
|
|
5765
7235
|
copyForAgent(comment) {
|
|
5766
7236
|
const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
|
|
5767
|
-
|
|
7237
|
+
const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
|
|
7238
|
+
copyText(buildCommentPrompt(detail ?? {
|
|
7239
|
+
...comment,
|
|
7240
|
+
replies
|
|
7241
|
+
}, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
|
|
5768
7242
|
}
|
|
5769
7243
|
renderHighlight() {
|
|
5770
7244
|
this.overlayLayer.replaceChildren();
|
|
5771
7245
|
if (!this.picking) return;
|
|
5772
|
-
|
|
7246
|
+
const mobile = window.matchMedia(MOBILE_QUERY).matches;
|
|
7247
|
+
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"))]));
|
|
7248
|
+
if (this.dragBox) {
|
|
7249
|
+
const box = h("div", { class: "highlight drag-box" });
|
|
7250
|
+
Object.assign(box.style, {
|
|
7251
|
+
left: `${this.dragBox.left}px`,
|
|
7252
|
+
top: `${this.dragBox.top}px`,
|
|
7253
|
+
width: `${this.dragBox.width}px`,
|
|
7254
|
+
height: `${this.dragBox.height}px`
|
|
7255
|
+
});
|
|
7256
|
+
this.overlayLayer.append(box);
|
|
7257
|
+
return;
|
|
7258
|
+
}
|
|
5773
7259
|
const el = this.hoverEl;
|
|
5774
|
-
if (!el || el ===
|
|
5775
|
-
const r = el
|
|
7260
|
+
if (!el || el === el.ownerDocument.documentElement) return;
|
|
7261
|
+
const r = viewportRect(el, document);
|
|
5776
7262
|
const box = h("div", { class: "highlight" }, h("span", { class: "highlight-label" }, describeElement(el)));
|
|
5777
7263
|
Object.assign(box.style, {
|
|
5778
7264
|
left: `${r.left - 2}px`,
|
|
@@ -5785,18 +7271,170 @@ var NuniWidget = class {
|
|
|
5785
7271
|
onKeyDown(e) {
|
|
5786
7272
|
const inWidget = e.composedPath().includes(this.host);
|
|
5787
7273
|
if (e.key === "Escape") {
|
|
5788
|
-
if (this.
|
|
7274
|
+
if (this.confirming) this.answer(false);
|
|
7275
|
+
else if (this.picking) this.setPicking(false);
|
|
5789
7276
|
else if (this.card) this.closeCard();
|
|
5790
7277
|
else if (this.panelOpen) this.togglePanel(false);
|
|
5791
7278
|
else return;
|
|
5792
7279
|
e.stopPropagation();
|
|
5793
7280
|
return;
|
|
5794
7281
|
}
|
|
5795
|
-
if (
|
|
5796
|
-
if (
|
|
7282
|
+
if (isTypingTarget(e.composedPath()[0] ?? e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
7283
|
+
if (this.picking && !inWidget && this.onPickKey(e)) return;
|
|
7284
|
+
if (inWidget && this.confirming) return;
|
|
7285
|
+
if (e.repeat) return;
|
|
7286
|
+
const hotkey = this.config.hotkey;
|
|
7287
|
+
if (hotkey && e.key.toLowerCase() === hotkey) {
|
|
7288
|
+
e.preventDefault();
|
|
7289
|
+
if (!this.picking && this.pageSelection()) this.commentOnSelection();
|
|
7290
|
+
else this.setPicking(!this.picking);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
/**
|
|
7294
|
+
* Picking without a mouse: arrows move to the parent, first child and
|
|
7295
|
+
* siblings; Enter comments on the highlighted element.
|
|
7296
|
+
*/
|
|
7297
|
+
onPickKey(e) {
|
|
7298
|
+
if (![
|
|
7299
|
+
"ArrowUp",
|
|
7300
|
+
"ArrowDown",
|
|
7301
|
+
"ArrowLeft",
|
|
7302
|
+
"ArrowRight",
|
|
7303
|
+
"Enter"
|
|
7304
|
+
].includes(e.key)) return false;
|
|
7305
|
+
e.preventDefault();
|
|
7306
|
+
e.stopPropagation();
|
|
7307
|
+
let el = this.hoverEl;
|
|
7308
|
+
if (!el || !el.isConnected) {
|
|
7309
|
+
this.movePickTo(this.keyboardStart());
|
|
7310
|
+
return true;
|
|
7311
|
+
}
|
|
7312
|
+
if (e.key === "Enter") {
|
|
7313
|
+
const r = el.getBoundingClientRect();
|
|
7314
|
+
const anchor = captureAnchor(el, {
|
|
7315
|
+
x: r.left + r.width / 2,
|
|
7316
|
+
y: r.top + r.height / 2
|
|
7317
|
+
}, {
|
|
7318
|
+
ignoreAttributePrefixes: [],
|
|
7319
|
+
document
|
|
7320
|
+
});
|
|
7321
|
+
this.openComposer({
|
|
7322
|
+
anchor,
|
|
7323
|
+
element: el
|
|
7324
|
+
});
|
|
7325
|
+
return true;
|
|
7326
|
+
}
|
|
7327
|
+
const visible = (n) => {
|
|
7328
|
+
while (n && (n === this.host || !isRendered(n))) n = e.key === "ArrowLeft" ? n.previousElementSibling : n.nextElementSibling;
|
|
7329
|
+
return n;
|
|
7330
|
+
};
|
|
7331
|
+
const body = el.ownerDocument.body;
|
|
7332
|
+
if (e.key === "ArrowUp") {
|
|
7333
|
+
if (el.parentElement && el !== body) el = el.parentElement;
|
|
7334
|
+
} else if (e.key === "ArrowDown") el = visible(el.firstElementChild) ?? el;
|
|
7335
|
+
else if (e.key === "ArrowLeft") el = visible(el.previousElementSibling) ?? el;
|
|
7336
|
+
else el = visible(el.nextElementSibling) ?? el;
|
|
7337
|
+
this.movePickTo(el);
|
|
7338
|
+
return true;
|
|
7339
|
+
}
|
|
7340
|
+
/** Keyboard picking starts at the focused element, or mid-screen. */
|
|
7341
|
+
keyboardStart() {
|
|
7342
|
+
const focused = document.activeElement;
|
|
7343
|
+
if (focused && focused !== document.body && focused !== this.host) return focused;
|
|
7344
|
+
const middle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2).find((el) => !this.isIgnored(el));
|
|
7345
|
+
return middle ? pickTarget(middle) : document.body;
|
|
7346
|
+
}
|
|
7347
|
+
movePickTo(el) {
|
|
7348
|
+
this.hoverEl = el;
|
|
7349
|
+
el.scrollIntoView({
|
|
7350
|
+
block: "nearest",
|
|
7351
|
+
inline: "nearest"
|
|
7352
|
+
});
|
|
7353
|
+
this.live.textContent = this.t("picked", { element: describeElement(el) });
|
|
7354
|
+
this.renderHighlight();
|
|
7355
|
+
}
|
|
7356
|
+
/** Tab stays inside an open card, the panel or a dialog. */
|
|
7357
|
+
trapFocus(e) {
|
|
7358
|
+
if (e.key !== "Tab") return;
|
|
7359
|
+
const box = e.composedPath()[0]?.closest?.(".confirm, [data-card], .panel");
|
|
7360
|
+
if (!box) return;
|
|
7361
|
+
const items = Array.from(box.querySelectorAll("button:not([disabled]), input, textarea, a[href], [tabindex=\"0\"]")).filter((el) => !el.closest("[hidden]"));
|
|
7362
|
+
if (!items.length) return;
|
|
7363
|
+
const first = items[0];
|
|
7364
|
+
const last = items[items.length - 1];
|
|
7365
|
+
const active = this.root.activeElement;
|
|
7366
|
+
if (e.shiftKey && active === first) {
|
|
7367
|
+
e.preventDefault();
|
|
7368
|
+
last.focus();
|
|
7369
|
+
} else if (!e.shiftKey && active === last) {
|
|
5797
7370
|
e.preventDefault();
|
|
5798
|
-
|
|
7371
|
+
first.focus();
|
|
7372
|
+
}
|
|
7373
|
+
}
|
|
7374
|
+
/** Remember where focus was, before moving it into the widget. */
|
|
7375
|
+
keepFocus() {
|
|
7376
|
+
if (this.returnFocus) return;
|
|
7377
|
+
const inside = this.root.activeElement;
|
|
7378
|
+
if (inside) {
|
|
7379
|
+
this.returnFocus = inside.dataset.focusKey ?? inside;
|
|
7380
|
+
return;
|
|
5799
7381
|
}
|
|
7382
|
+
const active = document.activeElement;
|
|
7383
|
+
this.returnFocus = active && active !== document.body && active !== this.host ? active : null;
|
|
7384
|
+
}
|
|
7385
|
+
/** Back to where focus was before (when that is still there). */
|
|
7386
|
+
restoreFocus() {
|
|
7387
|
+
const back = this.returnFocus;
|
|
7388
|
+
this.returnFocus = null;
|
|
7389
|
+
const target = typeof back === "string" ? this.uiLayer.querySelector(`[data-focus-key="${back}"]`) : back;
|
|
7390
|
+
if (target?.isConnected) target.focus({ preventScroll: true });
|
|
7391
|
+
}
|
|
7392
|
+
/** An "are you sure?" dialog inside the widget. */
|
|
7393
|
+
ask(message, action) {
|
|
7394
|
+
this.confirming?.done(false);
|
|
7395
|
+
const from = this.root.activeElement?.dataset.focusKey ?? null;
|
|
7396
|
+
return new Promise((resolve) => {
|
|
7397
|
+
this.confirming = {
|
|
7398
|
+
message,
|
|
7399
|
+
action,
|
|
7400
|
+
done: resolve,
|
|
7401
|
+
from
|
|
7402
|
+
};
|
|
7403
|
+
this.focusNext = "[data-focus-key=\"confirm-cancel\"]";
|
|
7404
|
+
this.render();
|
|
7405
|
+
});
|
|
7406
|
+
}
|
|
7407
|
+
answer(ok) {
|
|
7408
|
+
const confirming = this.confirming;
|
|
7409
|
+
if (!confirming) return;
|
|
7410
|
+
this.confirming = null;
|
|
7411
|
+
if (!ok && confirming.from) this.focusNext = `[data-focus-key="${confirming.from}"]`;
|
|
7412
|
+
this.render();
|
|
7413
|
+
confirming.done(ok);
|
|
7414
|
+
}
|
|
7415
|
+
renderConfirm() {
|
|
7416
|
+
const c = this.confirming;
|
|
7417
|
+
if (!c) return null;
|
|
7418
|
+
return h("div", {
|
|
7419
|
+
class: "confirm-backdrop",
|
|
7420
|
+
onclick: () => this.answer(false)
|
|
7421
|
+
}, h("div", {
|
|
7422
|
+
class: "confirm",
|
|
7423
|
+
role: "alertdialog",
|
|
7424
|
+
"aria-modal": "true",
|
|
7425
|
+
"aria-labelledby": "nuni-confirm-text",
|
|
7426
|
+
onclick: (e) => e.stopPropagation()
|
|
7427
|
+
}, h("p", { id: "nuni-confirm-text" }, c.message), h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
7428
|
+
class: "btn btn-ghost",
|
|
7429
|
+
type: "button",
|
|
7430
|
+
"data-focus-key": "confirm-cancel",
|
|
7431
|
+
onclick: () => this.answer(false)
|
|
7432
|
+
}, this.t("cancel")), h("button", {
|
|
7433
|
+
class: "btn btn-danger-solid",
|
|
7434
|
+
type: "button",
|
|
7435
|
+
"data-focus-key": "confirm-ok",
|
|
7436
|
+
onclick: () => this.answer(true)
|
|
7437
|
+
}, c.action))));
|
|
5800
7438
|
}
|
|
5801
7439
|
openClaim() {
|
|
5802
7440
|
const url = new URL(`${this.config.appUrl}/dashboard/claim`);
|
|
@@ -5818,7 +7456,7 @@ var NuniWidget = class {
|
|
|
5818
7456
|
this.ownerToken = data.token;
|
|
5819
7457
|
write(KEYS.session(this.config.project), data.token);
|
|
5820
7458
|
this.watchSession(data.token);
|
|
5821
|
-
this.showToast("
|
|
7459
|
+
this.showToast(this.t("signedIn"));
|
|
5822
7460
|
}
|
|
5823
7461
|
readDeepLink() {
|
|
5824
7462
|
const url = new URL(location.href);
|
|
@@ -5839,12 +7477,12 @@ var NuniWidget = class {
|
|
|
5839
7477
|
}
|
|
5840
7478
|
this.api.getComment(id).then((found) => {
|
|
5841
7479
|
if (!found || found.page.path !== this.pageKey) {
|
|
5842
|
-
this.showToast("
|
|
7480
|
+
this.showToast(this.t("linkGone"));
|
|
5843
7481
|
return;
|
|
5844
7482
|
}
|
|
5845
7483
|
this.extraComment = found;
|
|
5846
7484
|
this.comments = [...this.comments.filter((c) => c._id !== found._id), found];
|
|
5847
|
-
this.resolvePlacements(false);
|
|
7485
|
+
this.resolvePlacements(false, found._id);
|
|
5848
7486
|
this.focusDeepLinked(found);
|
|
5849
7487
|
});
|
|
5850
7488
|
}
|
|
@@ -5856,11 +7494,7 @@ var NuniWidget = class {
|
|
|
5856
7494
|
block: "center",
|
|
5857
7495
|
behavior: "smooth"
|
|
5858
7496
|
});
|
|
5859
|
-
this.
|
|
5860
|
-
this.card = {
|
|
5861
|
-
kind: "thread",
|
|
5862
|
-
id
|
|
5863
|
-
};
|
|
7497
|
+
this.openThread(id);
|
|
5864
7498
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5865
7499
|
this.render();
|
|
5866
7500
|
setTimeout(() => this.scheduleLayout(), 400);
|
|
@@ -5868,9 +7502,9 @@ var NuniWidget = class {
|
|
|
5868
7502
|
async submitDraft(draft, body, name) {
|
|
5869
7503
|
const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
|
|
5870
7504
|
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,
|
|
7505
|
+
if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
|
|
7506
|
+
if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
|
|
7507
|
+
if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
|
|
5874
7508
|
this.name = cleanName;
|
|
5875
7509
|
write(KEYS.name, cleanName);
|
|
5876
7510
|
draft.sending = true;
|
|
@@ -5899,13 +7533,14 @@ var NuniWidget = class {
|
|
|
5899
7533
|
if (shot) this.attachScreenshot(id, shot.blob);
|
|
5900
7534
|
this.placements.set(id, {
|
|
5901
7535
|
element: draft.element,
|
|
5902
|
-
confidence: "exact"
|
|
7536
|
+
confidence: "exact",
|
|
7537
|
+
range: draft.range
|
|
5903
7538
|
});
|
|
5904
7539
|
if (this.card?.kind === "composer" && this.card.draft === draft) this.card = null;
|
|
5905
|
-
this.showToast("
|
|
7540
|
+
this.showToast(this.t("added"));
|
|
5906
7541
|
} catch (error) {
|
|
5907
7542
|
draft.sending = false;
|
|
5908
|
-
draft.error = error instanceof NuniApiError ? error.message :
|
|
7543
|
+
draft.error = error instanceof NuniApiError ? error.message : this.t("postFailed");
|
|
5909
7544
|
}
|
|
5910
7545
|
this.render();
|
|
5911
7546
|
}
|
|
@@ -5933,7 +7568,7 @@ var NuniWidget = class {
|
|
|
5933
7568
|
} catch (error) {
|
|
5934
7569
|
if (this.card?.kind === "thread" && this.card.id === id) {
|
|
5935
7570
|
this.card.busy = false;
|
|
5936
|
-
this.card.error = error instanceof Error ? error.message : "
|
|
7571
|
+
this.card.error = error instanceof Error ? error.message : this.t("failed");
|
|
5937
7572
|
}
|
|
5938
7573
|
this.render();
|
|
5939
7574
|
return false;
|
|
@@ -5942,21 +7577,21 @@ var NuniWidget = class {
|
|
|
5942
7577
|
resolveComment(c) {
|
|
5943
7578
|
const token = this.ownerToken;
|
|
5944
7579
|
if (!token) return;
|
|
5945
|
-
this.act(c._id, () => this.api.resolve(c._id, token), "
|
|
7580
|
+
this.act(c._id, () => this.api.resolve(c._id, token), this.t("resolved")).then((ok) => {
|
|
5946
7581
|
if (ok && !(this.panelOpen && this.tab === "resolved") && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
|
|
5947
7582
|
});
|
|
5948
7583
|
}
|
|
5949
7584
|
reopenComment(c) {
|
|
5950
7585
|
const token = this.ownerToken;
|
|
5951
7586
|
if (!token) return;
|
|
5952
|
-
this.act(c._id, () => this.api.reopen(c._id, token), "
|
|
7587
|
+
this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
|
|
5953
7588
|
}
|
|
5954
|
-
deleteComment(c) {
|
|
5955
|
-
if (!
|
|
7589
|
+
async deleteComment(c) {
|
|
7590
|
+
if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
|
|
5956
7591
|
const token = this.ownerToken;
|
|
5957
7592
|
const action = this.isMine(c) ? () => this.api.deleteOwn(c._id, this.secret) : token ? () => this.api.remove(c._id, token) : null;
|
|
5958
7593
|
if (!action) return;
|
|
5959
|
-
this.act(c._id, action, "
|
|
7594
|
+
this.act(c._id, action, this.t("deleted")).then((ok) => {
|
|
5960
7595
|
if (!ok) return;
|
|
5961
7596
|
if (this.extraComment?._id === c._id) this.extraComment = null;
|
|
5962
7597
|
this.comments = this.comments.filter((x) => x._id !== c._id);
|
|
@@ -5964,8 +7599,72 @@ var NuniWidget = class {
|
|
|
5964
7599
|
this.closeCard();
|
|
5965
7600
|
});
|
|
5966
7601
|
}
|
|
7602
|
+
isMyReply(reply) {
|
|
7603
|
+
return Boolean(this.secretHash) && reply.authorKeyHash === this.secretHash;
|
|
7604
|
+
}
|
|
7605
|
+
async sendReply(comment, body, name) {
|
|
7606
|
+
const card = this.card;
|
|
7607
|
+
if (card?.kind !== "thread" || card.id !== comment._id) return;
|
|
7608
|
+
const authorName = (this.isOwner ? this.ownerName : name)?.trim().slice(0, LIMITS.nameMaxLength);
|
|
7609
|
+
const clean = body.trim();
|
|
7610
|
+
if (!clean) return;
|
|
7611
|
+
if (!authorName) {
|
|
7612
|
+
card.error = this.t("enterName");
|
|
7613
|
+
this.render();
|
|
7614
|
+
return;
|
|
7615
|
+
}
|
|
7616
|
+
if (clean.length > LIMITS.bodyMaxLength) {
|
|
7617
|
+
card.error = this.t("replyTooLong", { count: LIMITS.bodyMaxLength });
|
|
7618
|
+
this.render();
|
|
7619
|
+
return;
|
|
7620
|
+
}
|
|
7621
|
+
if (!this.isOwner) {
|
|
7622
|
+
this.name = authorName;
|
|
7623
|
+
write(KEYS.name, authorName);
|
|
7624
|
+
}
|
|
7625
|
+
card.sendingReply = true;
|
|
7626
|
+
card.error = void 0;
|
|
7627
|
+
this.render();
|
|
7628
|
+
try {
|
|
7629
|
+
await this.api.createReply({
|
|
7630
|
+
commentId: comment._id,
|
|
7631
|
+
body: clean,
|
|
7632
|
+
authorName,
|
|
7633
|
+
authorSecret: this.secret,
|
|
7634
|
+
sessionToken: this.ownerToken ?? void 0
|
|
7635
|
+
});
|
|
7636
|
+
const box = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
7637
|
+
if (box) box.value = "";
|
|
7638
|
+
} catch (error) {
|
|
7639
|
+
card.error = error instanceof NuniApiError ? error.message : this.t("replyFailed");
|
|
7640
|
+
}
|
|
7641
|
+
card.sendingReply = false;
|
|
7642
|
+
this.render();
|
|
7643
|
+
}
|
|
7644
|
+
toggleReaction(comment, targetId, emoji) {
|
|
7645
|
+
this.api.toggleReaction(comment._id, targetId, emoji, this.secret).catch((error) => {
|
|
7646
|
+
if (this.card?.kind === "thread" && this.card.id === comment._id) {
|
|
7647
|
+
this.card.error = error instanceof Error ? error.message : this.t("reactFailed");
|
|
7648
|
+
this.render();
|
|
7649
|
+
}
|
|
7650
|
+
});
|
|
7651
|
+
}
|
|
7652
|
+
async deleteReply(comment, reply) {
|
|
7653
|
+
if (!await this.ask(this.t("confirmDeleteReply"), this.t("delete"))) return;
|
|
7654
|
+
const token = this.ownerToken;
|
|
7655
|
+
const action = this.isMyReply(reply) ? () => this.api.deleteReply(reply._id, this.secret) : token ? () => this.api.removeReply(reply._id, token) : null;
|
|
7656
|
+
if (action) this.act(comment._id, action, this.t("replyDeleted"));
|
|
7657
|
+
}
|
|
7658
|
+
saveReplyEdit(comment, reply, body) {
|
|
7659
|
+
this.act(comment._id, () => this.api.editReply(reply._id, this.secret, body), this.t("saved")).then((ok) => {
|
|
7660
|
+
if (ok && this.card?.kind === "thread") {
|
|
7661
|
+
this.card.replyEditing = void 0;
|
|
7662
|
+
this.render();
|
|
7663
|
+
}
|
|
7664
|
+
});
|
|
7665
|
+
}
|
|
5967
7666
|
saveEdit(c, body) {
|
|
5968
|
-
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), "
|
|
7667
|
+
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
|
|
5969
7668
|
}
|
|
5970
7669
|
showToast(message) {
|
|
5971
7670
|
this.toast = message;
|
|
@@ -5977,17 +7676,36 @@ var NuniWidget = class {
|
|
|
5977
7676
|
this.render();
|
|
5978
7677
|
}
|
|
5979
7678
|
togglePanel(open = !this.panelOpen) {
|
|
7679
|
+
if (open === this.panelOpen) return;
|
|
5980
7680
|
this.panelOpen = open;
|
|
5981
|
-
if (open
|
|
7681
|
+
if (open) {
|
|
7682
|
+
this.keepFocus();
|
|
7683
|
+
this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
|
|
7684
|
+
if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
|
|
7685
|
+
}
|
|
5982
7686
|
this.render();
|
|
7687
|
+
if (!open && !this.card) this.restoreFocus();
|
|
5983
7688
|
}
|
|
5984
7689
|
closeCard() {
|
|
7690
|
+
const had = this.card !== null;
|
|
5985
7691
|
this.card = null;
|
|
5986
7692
|
this.activeId = null;
|
|
5987
7693
|
this.render();
|
|
7694
|
+
if (had && !this.panelOpen) this.restoreFocus();
|
|
7695
|
+
}
|
|
7696
|
+
/** Open a comment's thread and move focus into it. */
|
|
7697
|
+
openThread(id) {
|
|
7698
|
+
this.keepFocus();
|
|
7699
|
+
this.activeId = id;
|
|
7700
|
+
this.card = {
|
|
7701
|
+
kind: "thread",
|
|
7702
|
+
id
|
|
7703
|
+
};
|
|
7704
|
+
this.focusNext = "[data-card=\"thread\"] .card-head button";
|
|
5988
7705
|
}
|
|
5989
7706
|
render() {
|
|
5990
7707
|
this.syncOwnerDetail();
|
|
7708
|
+
this.syncThread();
|
|
5991
7709
|
this.renderPins();
|
|
5992
7710
|
this.renderUi();
|
|
5993
7711
|
this.renderHighlight();
|
|
@@ -6013,11 +7731,7 @@ var NuniWidget = class {
|
|
|
6013
7731
|
e.stopPropagation();
|
|
6014
7732
|
if (this.card?.kind === "thread" && this.card.id === id) this.closeCard();
|
|
6015
7733
|
else {
|
|
6016
|
-
this.
|
|
6017
|
-
this.card = {
|
|
6018
|
-
kind: "thread",
|
|
6019
|
-
id
|
|
6020
|
-
};
|
|
7734
|
+
this.openThread(id);
|
|
6021
7735
|
this.render();
|
|
6022
7736
|
}
|
|
6023
7737
|
});
|
|
@@ -6026,7 +7740,12 @@ var NuniWidget = class {
|
|
|
6026
7740
|
}
|
|
6027
7741
|
pin.textContent = initials(comment.authorName);
|
|
6028
7742
|
pin.style.background = colorFor(comment.authorName);
|
|
6029
|
-
pin.setAttribute("aria-label",
|
|
7743
|
+
pin.setAttribute("aria-label", this.t("pinLabel", {
|
|
7744
|
+
name: comment.authorName,
|
|
7745
|
+
body: comment.body.slice(0, 80)
|
|
7746
|
+
}));
|
|
7747
|
+
if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
|
|
7748
|
+
else delete pin.dataset.replies;
|
|
6030
7749
|
pin.dataset.status = comment.status;
|
|
6031
7750
|
pin.dataset.active = String(this.activeId === comment._id);
|
|
6032
7751
|
pin.dataset.confidence = this.placements.get(comment._id)?.confidence ?? "lost";
|
|
@@ -6034,9 +7753,7 @@ var NuniWidget = class {
|
|
|
6034
7753
|
this.pinLayer.querySelector(".pin-draft")?.remove();
|
|
6035
7754
|
if (this.card?.kind === "composer") {
|
|
6036
7755
|
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;
|
|
7756
|
+
const { x, y } = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
6040
7757
|
const pin = h("div", {
|
|
6041
7758
|
class: "pin pin-draft pin-pending",
|
|
6042
7759
|
"aria-hidden": "true"
|
|
@@ -6055,6 +7772,8 @@ var NuniWidget = class {
|
|
|
6055
7772
|
class: "toast",
|
|
6056
7773
|
role: "status"
|
|
6057
7774
|
}, this.toast));
|
|
7775
|
+
const confirm = this.renderConfirm();
|
|
7776
|
+
if (confirm) nodes.push(confirm);
|
|
6058
7777
|
const focused = this.root.activeElement;
|
|
6059
7778
|
const focusKey = focused?.dataset.focusKey;
|
|
6060
7779
|
const selection = focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement ? {
|
|
@@ -6062,7 +7781,11 @@ var NuniWidget = class {
|
|
|
6062
7781
|
end: focused.selectionEnd
|
|
6063
7782
|
} : null;
|
|
6064
7783
|
this.uiLayer.replaceChildren(...nodes);
|
|
6065
|
-
|
|
7784
|
+
const next = this.focusNext;
|
|
7785
|
+
this.focusNext = null;
|
|
7786
|
+
const wanted = next ? this.uiLayer.querySelector(next) : null;
|
|
7787
|
+
if (wanted) wanted.focus();
|
|
7788
|
+
else if (focusKey) {
|
|
6066
7789
|
const next = this.uiLayer.querySelector(`[data-focus-key="${focusKey}"]`);
|
|
6067
7790
|
next?.focus();
|
|
6068
7791
|
if (selection && (next instanceof HTMLTextAreaElement || next instanceof HTMLInputElement)) next.setSelectionRange(selection.start, selection.end);
|
|
@@ -6074,20 +7797,22 @@ var NuniWidget = class {
|
|
|
6074
7797
|
return h("div", {
|
|
6075
7798
|
class: "toolbar",
|
|
6076
7799
|
role: "toolbar",
|
|
6077
|
-
"aria-label": "
|
|
7800
|
+
"aria-label": this.t("toolbar")
|
|
6078
7801
|
}, h("button", {
|
|
6079
7802
|
class: "tb-btn",
|
|
6080
7803
|
type: "button",
|
|
6081
7804
|
"aria-pressed": String(this.picking),
|
|
6082
|
-
"
|
|
6083
|
-
|
|
7805
|
+
"data-focus-key": "tb-comment",
|
|
7806
|
+
"aria-label": this.picking ? this.t("cancelAdding") : this.t("addComment"),
|
|
7807
|
+
title: this.withKey("addComment", "addCommentKey"),
|
|
6084
7808
|
onclick: () => this.setPicking(!this.picking)
|
|
6085
|
-
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? "
|
|
7809
|
+
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? this.t("pickElement") : this.config.label ?? this.t("comment"))), h("button", {
|
|
6086
7810
|
class: "tb-btn",
|
|
6087
7811
|
type: "button",
|
|
6088
7812
|
"aria-pressed": String(this.panelOpen),
|
|
6089
|
-
"
|
|
6090
|
-
|
|
7813
|
+
"data-focus-key": "tb-panel",
|
|
7814
|
+
"aria-label": this.t("openCount", { count: open }),
|
|
7815
|
+
title: this.t("allComments"),
|
|
6091
7816
|
onclick: () => this.togglePanel()
|
|
6092
7817
|
}, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
|
|
6093
7818
|
}
|
|
@@ -6101,72 +7826,87 @@ var NuniWidget = class {
|
|
|
6101
7826
|
const item = (c) => h("button", {
|
|
6102
7827
|
class: "item",
|
|
6103
7828
|
type: "button",
|
|
7829
|
+
"data-focus-key": `item-${c._id}`,
|
|
6104
7830
|
onclick: () => this.focusComment(c._id)
|
|
6105
7831
|
}, h("div", { class: "row" }, h("span", {
|
|
6106
7832
|
class: "avatar",
|
|
6107
7833
|
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", {
|
|
7834
|
+
}, 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
7835
|
class: "badge",
|
|
6110
7836
|
title: c.page.origin
|
|
6111
|
-
}, new URL(c.page.origin).host) : null), h("div", { class: "item-text" }, c.body));
|
|
7837
|
+
}, 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
7838
|
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" ?
|
|
7839
|
+
if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
|
|
7840
|
+
else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
|
|
6115
7841
|
else {
|
|
6116
7842
|
children.push(...placed.map(item));
|
|
6117
7843
|
if (lost.length) {
|
|
6118
|
-
children.push(h("div", { class: "section-label" },
|
|
7844
|
+
children.push(h("div", { class: "section-label" }, this.t("notFoundHere")));
|
|
6119
7845
|
children.push(...lost.map(item));
|
|
6120
7846
|
}
|
|
6121
7847
|
}
|
|
6122
7848
|
if (otherPages.length) {
|
|
6123
|
-
children.push(h("div", { class: "section-label" }, "
|
|
7849
|
+
children.push(h("div", { class: "section-label" }, this.t("otherPages")));
|
|
6124
7850
|
for (const p of otherPages.slice(0, 20)) {
|
|
6125
|
-
const href = p.path
|
|
7851
|
+
const href = /[?#]/.test(p.path) ? location.origin + p.path : location.origin + p.path + location.search;
|
|
6126
7852
|
children.push(h("a", {
|
|
6127
7853
|
class: "item",
|
|
6128
7854
|
href
|
|
6129
|
-
}, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" },
|
|
7855
|
+
}, 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
7856
|
}
|
|
6131
7857
|
}
|
|
6132
7858
|
return h("div", {
|
|
6133
7859
|
class: "panel",
|
|
6134
7860
|
role: "dialog",
|
|
6135
|
-
"aria-label": "
|
|
6136
|
-
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, "
|
|
7861
|
+
"aria-label": this.t("comments")
|
|
7862
|
+
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, this.t("comments")), h("button", {
|
|
6137
7863
|
class: "btn btn-ghost btn-icon",
|
|
6138
7864
|
type: "button",
|
|
6139
|
-
"aria-label": "
|
|
7865
|
+
"aria-label": this.t("close"),
|
|
6140
7866
|
onclick: () => this.togglePanel(false)
|
|
6141
7867
|
}, icon(ICONS.close))), h("div", {
|
|
6142
7868
|
class: "tabs",
|
|
6143
|
-
role: "tablist"
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
7869
|
+
role: "tablist",
|
|
7870
|
+
"aria-label": this.t("comments"),
|
|
7871
|
+
onkeydown: (e) => {
|
|
7872
|
+
const key = e.key;
|
|
7873
|
+
if (![
|
|
7874
|
+
"ArrowLeft",
|
|
7875
|
+
"ArrowRight",
|
|
7876
|
+
"Home",
|
|
7877
|
+
"End"
|
|
7878
|
+
].includes(key)) return;
|
|
7879
|
+
e.preventDefault();
|
|
7880
|
+
this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
|
|
7881
|
+
this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
|
|
6151
7882
|
this.render();
|
|
6152
7883
|
}
|
|
6153
|
-
},
|
|
7884
|
+
}, ["open", "resolved"].map((tab) => h("button", {
|
|
6154
7885
|
class: "tab",
|
|
6155
7886
|
role: "tab",
|
|
6156
7887
|
type: "button",
|
|
6157
|
-
|
|
7888
|
+
id: `nuni-tab-${tab}`,
|
|
7889
|
+
"data-focus-key": `tab-${tab}`,
|
|
7890
|
+
"aria-selected": String(this.tab === tab),
|
|
7891
|
+
"aria-controls": "nuni-panel-list",
|
|
7892
|
+
tabindex: this.tab === tab ? 0 : -1,
|
|
6158
7893
|
onclick: () => {
|
|
6159
|
-
this.tab =
|
|
7894
|
+
this.tab = tab;
|
|
6160
7895
|
this.render();
|
|
6161
7896
|
}
|
|
6162
|
-
},
|
|
7897
|
+
}, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
|
|
7898
|
+
class: "panel-list",
|
|
7899
|
+
id: "nuni-panel-list",
|
|
7900
|
+
role: "tabpanel",
|
|
7901
|
+
"aria-labelledby": `nuni-tab-${this.tab}`
|
|
7902
|
+
}, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
|
|
6163
7903
|
}
|
|
6164
7904
|
renderPanelFooter() {
|
|
6165
|
-
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" },
|
|
7905
|
+
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, this.t("ownSite")), h("span", { class: "spacer" }), h("button", {
|
|
6166
7906
|
class: "btn",
|
|
6167
7907
|
type: "button",
|
|
6168
7908
|
onclick: () => this.openClaim()
|
|
6169
|
-
}, icon(ICONS.github), "
|
|
7909
|
+
}, icon(ICONS.github), this.t("claim"))));
|
|
6170
7910
|
}
|
|
6171
7911
|
renderCard() {
|
|
6172
7912
|
const card = this.card;
|
|
@@ -6177,19 +7917,19 @@ var NuniWidget = class {
|
|
|
6177
7917
|
const needsName = !this.name;
|
|
6178
7918
|
const nameInput = h("input", {
|
|
6179
7919
|
class: "field",
|
|
6180
|
-
placeholder: "
|
|
7920
|
+
placeholder: this.t("yourName"),
|
|
6181
7921
|
value: this.name,
|
|
6182
7922
|
maxlength: LIMITS.nameMaxLength,
|
|
6183
7923
|
autocomplete: "name",
|
|
6184
7924
|
"data-focus-key": "name",
|
|
6185
|
-
"aria-label": "
|
|
7925
|
+
"aria-label": this.t("yourName")
|
|
6186
7926
|
});
|
|
6187
7927
|
const textarea = h("textarea", {
|
|
6188
7928
|
class: "field",
|
|
6189
|
-
placeholder: "
|
|
7929
|
+
placeholder: this.t("leaveComment"),
|
|
6190
7930
|
maxlength: LIMITS.bodyMaxLength,
|
|
6191
7931
|
"data-focus-key": "body",
|
|
6192
|
-
"aria-label": "
|
|
7932
|
+
"aria-label": this.t("comment")
|
|
6193
7933
|
});
|
|
6194
7934
|
const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
|
|
6195
7935
|
if (prev) textarea.value = prev.value;
|
|
@@ -6219,28 +7959,28 @@ var NuniWidget = class {
|
|
|
6219
7959
|
this.name = "";
|
|
6220
7960
|
this.render();
|
|
6221
7961
|
}
|
|
6222
|
-
}, "
|
|
7962
|
+
}, 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
7963
|
src: draft.screenshot.preview,
|
|
6224
|
-
alt: "
|
|
6225
|
-
}), h("div", { class: "shot-note" }, h("span", {},
|
|
7964
|
+
alt: this.t("screenshotAlt")
|
|
7965
|
+
}), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
|
|
6226
7966
|
class: "link",
|
|
6227
7967
|
type: "button",
|
|
6228
7968
|
onclick: () => {
|
|
6229
7969
|
draft.screenshot = void 0;
|
|
6230
7970
|
this.render();
|
|
6231
7971
|
}
|
|
6232
|
-
}, "
|
|
7972
|
+
}, this.t("remove")))) : null, draft.error ? h("div", {
|
|
6233
7973
|
class: "error",
|
|
6234
7974
|
role: "alert"
|
|
6235
|
-
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "
|
|
7975
|
+
}, 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
7976
|
class: "btn btn-ghost",
|
|
6237
7977
|
type: "button",
|
|
6238
7978
|
onclick: () => this.closeCard()
|
|
6239
|
-
}, "
|
|
7979
|
+
}, this.t("cancel")), h("button", {
|
|
6240
7980
|
class: "btn btn-primary",
|
|
6241
7981
|
type: "submit",
|
|
6242
7982
|
disabled: Boolean(draft.sending)
|
|
6243
|
-
}, icon(ICONS.send), draft.sending ? "
|
|
7983
|
+
}, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
|
|
6244
7984
|
}
|
|
6245
7985
|
const comment = this.comments.find((c) => c._id === card.id);
|
|
6246
7986
|
if (!comment) return null;
|
|
@@ -6263,39 +8003,39 @@ var NuniWidget = class {
|
|
|
6263
8003
|
card.editing = false;
|
|
6264
8004
|
this.render();
|
|
6265
8005
|
}
|
|
6266
|
-
}, "
|
|
8006
|
+
}, this.t("cancel")), h("button", {
|
|
6267
8007
|
class: "btn btn-primary",
|
|
6268
8008
|
type: "button",
|
|
6269
8009
|
disabled: Boolean(card.busy),
|
|
6270
8010
|
onclick: () => this.saveEdit(comment, textarea.value)
|
|
6271
|
-
}, "
|
|
8011
|
+
}, this.t("save"))));
|
|
6272
8012
|
} else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
|
|
6273
8013
|
class: "avatar",
|
|
6274
8014
|
style: `background:${colorFor(comment.authorName)}`
|
|
6275
|
-
}, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, timeAgo(comment.createdAt) + (comment.editedAt ?
|
|
8015
|
+
}, 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
8016
|
class: "btn btn-ghost btn-icon",
|
|
6277
8017
|
type: "button",
|
|
6278
|
-
"aria-label": "
|
|
8018
|
+
"aria-label": this.t("close"),
|
|
6279
8019
|
onclick: () => this.closeCard()
|
|
6280
|
-
}, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
|
|
8020
|
+
}, 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
8021
|
class: "shot",
|
|
6282
8022
|
href: this.ownerDetail.screenshotUrl,
|
|
6283
8023
|
target: "_blank",
|
|
6284
8024
|
rel: "noreferrer",
|
|
6285
|
-
title: "
|
|
8025
|
+
title: this.t("openScreenshot")
|
|
6286
8026
|
}, h("img", {
|
|
6287
8027
|
src: this.ownerDetail.screenshotUrl,
|
|
6288
|
-
alt: "
|
|
6289
|
-
})) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), "
|
|
8028
|
+
alt: this.t("screenshotTaken")
|
|
8029
|
+
})) : 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
8030
|
class: "badge",
|
|
6291
|
-
title:
|
|
8031
|
+
title: this.t("leftOn", { origin: comment.page.origin })
|
|
6292
8032
|
}, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
|
|
6293
8033
|
class: "badge",
|
|
6294
|
-
title:
|
|
6295
|
-
}, "
|
|
8034
|
+
title: this.t("approximateHint")
|
|
8035
|
+
}, this.t("approximate")) : null, !placement?.element ? h("span", {
|
|
6296
8036
|
class: "badge",
|
|
6297
|
-
title:
|
|
6298
|
-
}, "
|
|
8037
|
+
title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
|
|
8038
|
+
}, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
|
|
6299
8039
|
class: "error",
|
|
6300
8040
|
role: "alert"
|
|
6301
8041
|
}, card.error) : null);
|
|
@@ -6306,27 +8046,27 @@ var NuniWidget = class {
|
|
|
6306
8046
|
type: "button",
|
|
6307
8047
|
disabled: Boolean(card.busy),
|
|
6308
8048
|
onclick: () => this.resolveComment(comment)
|
|
6309
|
-
}, icon(ICONS.check), "
|
|
8049
|
+
}, icon(ICONS.check), this.t("resolve")) : h("button", {
|
|
6310
8050
|
class: "btn",
|
|
6311
8051
|
type: "button",
|
|
6312
8052
|
disabled: Boolean(card.busy),
|
|
6313
8053
|
onclick: () => this.reopenComment(comment)
|
|
6314
|
-
}, icon(ICONS.undo), "
|
|
8054
|
+
}, icon(ICONS.undo), this.t("reopen")));
|
|
6315
8055
|
actions.push(h("span", { class: "spacer" }));
|
|
6316
8056
|
const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
|
|
6317
8057
|
actions.push(h("button", {
|
|
6318
8058
|
class: "btn btn-ghost btn-icon",
|
|
6319
8059
|
type: "button",
|
|
6320
|
-
"aria-label": "
|
|
6321
|
-
title: contextPending ? "
|
|
8060
|
+
"aria-label": this.t("copyForAgent"),
|
|
8061
|
+
title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
|
|
6322
8062
|
disabled: contextPending,
|
|
6323
8063
|
onclick: () => this.copyForAgent(comment)
|
|
6324
8064
|
}, icon(ICONS.bot)));
|
|
6325
8065
|
if (mine) actions.push(h("button", {
|
|
6326
8066
|
class: "btn btn-ghost btn-icon",
|
|
6327
8067
|
type: "button",
|
|
6328
|
-
"aria-label": "
|
|
6329
|
-
title: "
|
|
8068
|
+
"aria-label": this.t("edit"),
|
|
8069
|
+
title: this.t("edit"),
|
|
6330
8070
|
onclick: () => {
|
|
6331
8071
|
card.editing = true;
|
|
6332
8072
|
this.render();
|
|
@@ -6335,18 +8075,164 @@ var NuniWidget = class {
|
|
|
6335
8075
|
if (mine || owner) actions.push(h("button", {
|
|
6336
8076
|
class: "btn btn-ghost btn-icon btn-danger",
|
|
6337
8077
|
type: "button",
|
|
6338
|
-
"aria-label": "
|
|
6339
|
-
title: "
|
|
8078
|
+
"aria-label": this.t("delete"),
|
|
8079
|
+
title: this.t("delete"),
|
|
8080
|
+
"data-focus-key": "delete",
|
|
6340
8081
|
disabled: Boolean(card.busy),
|
|
6341
|
-
onclick: () => this.deleteComment(comment)
|
|
8082
|
+
onclick: () => void this.deleteComment(comment)
|
|
6342
8083
|
}, icon(ICONS.trash)));
|
|
6343
8084
|
}
|
|
6344
8085
|
return h("div", {
|
|
6345
8086
|
class: cls,
|
|
6346
8087
|
"data-card": "thread",
|
|
6347
8088
|
role: "dialog",
|
|
6348
|
-
"aria-label":
|
|
6349
|
-
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
|
|
8089
|
+
"aria-label": this.t("commentBy", { name: comment.authorName })
|
|
8090
|
+
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
|
|
8091
|
+
}
|
|
8092
|
+
/** Emoji chips for one message, plus a picker to add one. */
|
|
8093
|
+
renderReactions(comment, targetId) {
|
|
8094
|
+
const card = this.card;
|
|
8095
|
+
if (card?.kind !== "thread") return null;
|
|
8096
|
+
const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
|
|
8097
|
+
const open = card.picker === targetId;
|
|
8098
|
+
return h("div", { class: "reactions" }, ...summaries.map((r) => {
|
|
8099
|
+
const mine = r.authorKeyHashes.includes(this.secretHash);
|
|
8100
|
+
return h("button", {
|
|
8101
|
+
class: "reaction",
|
|
8102
|
+
type: "button",
|
|
8103
|
+
"aria-pressed": String(mine),
|
|
8104
|
+
"aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
|
|
8105
|
+
onclick: () => this.toggleReaction(comment, targetId, r.emoji)
|
|
8106
|
+
}, `${r.emoji} ${r.count}`);
|
|
8107
|
+
}), h("button", {
|
|
8108
|
+
class: "reaction reaction-add",
|
|
8109
|
+
type: "button",
|
|
8110
|
+
"aria-label": this.t("addReaction"),
|
|
8111
|
+
"aria-expanded": String(open),
|
|
8112
|
+
title: this.t("addReaction"),
|
|
8113
|
+
onclick: () => {
|
|
8114
|
+
card.picker = open ? null : targetId;
|
|
8115
|
+
this.render();
|
|
8116
|
+
}
|
|
8117
|
+
}, icon(ICONS.smile)), open ? h("div", {
|
|
8118
|
+
class: "reaction-picker",
|
|
8119
|
+
role: "group",
|
|
8120
|
+
"aria-label": this.t("reactions")
|
|
8121
|
+
}, ...REACTIONS.map((emoji) => h("button", {
|
|
8122
|
+
class: "reaction",
|
|
8123
|
+
type: "button",
|
|
8124
|
+
"aria-label": this.t("reactWith", { emoji }),
|
|
8125
|
+
onclick: () => {
|
|
8126
|
+
card.picker = null;
|
|
8127
|
+
this.toggleReaction(comment, targetId, emoji);
|
|
8128
|
+
this.render();
|
|
8129
|
+
}
|
|
8130
|
+
}, emoji))) : null);
|
|
8131
|
+
}
|
|
8132
|
+
/** The replies under a comment and the box to add one. */
|
|
8133
|
+
renderThread(comment) {
|
|
8134
|
+
const card = this.card;
|
|
8135
|
+
if (card?.kind !== "thread") return null;
|
|
8136
|
+
const replies = this.threadId === comment._id ? this.thread?.replies ?? [] : [];
|
|
8137
|
+
const owner = this.isOwner;
|
|
8138
|
+
const items = replies.map((reply) => {
|
|
8139
|
+
const mine = this.isMyReply(reply);
|
|
8140
|
+
if (card.replyEditing === reply._id) {
|
|
8141
|
+
const box = h("textarea", {
|
|
8142
|
+
class: "field",
|
|
8143
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8144
|
+
"data-focus-key": `reply-edit-${reply._id}`,
|
|
8145
|
+
"aria-label": this.t("editReply")
|
|
8146
|
+
});
|
|
8147
|
+
const prev = this.uiLayer.querySelector(`[data-focus-key="reply-edit-${reply._id}"]`);
|
|
8148
|
+
box.value = prev ? prev.value : reply.body;
|
|
8149
|
+
return h("div", { class: "reply" }, box, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
8150
|
+
class: "btn btn-ghost",
|
|
8151
|
+
type: "button",
|
|
8152
|
+
onclick: () => {
|
|
8153
|
+
card.replyEditing = void 0;
|
|
8154
|
+
this.render();
|
|
8155
|
+
}
|
|
8156
|
+
}, this.t("cancel")), h("button", {
|
|
8157
|
+
class: "btn btn-primary",
|
|
8158
|
+
type: "button",
|
|
8159
|
+
disabled: Boolean(card.busy),
|
|
8160
|
+
onclick: () => this.saveReplyEdit(comment, reply, box.value)
|
|
8161
|
+
}, this.t("save"))));
|
|
8162
|
+
}
|
|
8163
|
+
return h("div", {
|
|
8164
|
+
class: "reply",
|
|
8165
|
+
"data-reply": reply._id
|
|
8166
|
+
}, h("div", { class: "card-head" }, h("span", {
|
|
8167
|
+
class: "avatar avatar-sm",
|
|
8168
|
+
style: `background:${colorFor(reply.authorName)}`
|
|
8169
|
+
}, 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", {
|
|
8170
|
+
class: "btn btn-ghost btn-icon btn-xs",
|
|
8171
|
+
type: "button",
|
|
8172
|
+
"aria-label": this.t("editReply"),
|
|
8173
|
+
title: this.t("edit"),
|
|
8174
|
+
onclick: () => {
|
|
8175
|
+
card.replyEditing = reply._id;
|
|
8176
|
+
this.render();
|
|
8177
|
+
}
|
|
8178
|
+
}, icon(ICONS.edit)) : null, mine || owner ? h("button", {
|
|
8179
|
+
class: "btn btn-ghost btn-icon btn-xs btn-danger",
|
|
8180
|
+
type: "button",
|
|
8181
|
+
"aria-label": this.t("deleteReply"),
|
|
8182
|
+
title: this.t("delete"),
|
|
8183
|
+
"data-focus-key": `delete-${reply._id}`,
|
|
8184
|
+
disabled: Boolean(card.busy),
|
|
8185
|
+
onclick: () => void this.deleteReply(comment, reply)
|
|
8186
|
+
}, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
|
|
8187
|
+
});
|
|
8188
|
+
const needsName = !owner && !this.name;
|
|
8189
|
+
const nameInput = h("input", {
|
|
8190
|
+
class: "field",
|
|
8191
|
+
placeholder: this.t("yourName"),
|
|
8192
|
+
value: this.name,
|
|
8193
|
+
maxlength: LIMITS.nameMaxLength,
|
|
8194
|
+
autocomplete: "name",
|
|
8195
|
+
"data-focus-key": "reply-name",
|
|
8196
|
+
"aria-label": this.t("yourName")
|
|
8197
|
+
});
|
|
8198
|
+
const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
|
|
8199
|
+
if (prevName) nameInput.value = prevName.value;
|
|
8200
|
+
const box = h("textarea", {
|
|
8201
|
+
class: "field field-reply",
|
|
8202
|
+
placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
|
|
8203
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8204
|
+
"data-focus-key": "reply",
|
|
8205
|
+
"aria-label": this.t("reply")
|
|
8206
|
+
});
|
|
8207
|
+
const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
8208
|
+
if (prev) box.value = prev.value;
|
|
8209
|
+
const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
|
|
8210
|
+
box.addEventListener("keydown", (e) => {
|
|
8211
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
8212
|
+
e.preventDefault();
|
|
8213
|
+
send();
|
|
8214
|
+
}
|
|
8215
|
+
});
|
|
8216
|
+
return h("div", { class: "thread" }, items.length ? h("div", {
|
|
8217
|
+
class: "replies",
|
|
8218
|
+
role: "list",
|
|
8219
|
+
"aria-label": this.t("replyCount", { count: items.length })
|
|
8220
|
+
}, ...items.map((item) => {
|
|
8221
|
+
item.setAttribute("role", "listitem");
|
|
8222
|
+
return item;
|
|
8223
|
+
})) : null, h("form", {
|
|
8224
|
+
class: "reply-form",
|
|
8225
|
+
onsubmit: (e) => {
|
|
8226
|
+
e.preventDefault();
|
|
8227
|
+
send();
|
|
8228
|
+
}
|
|
8229
|
+
}, needsName ? nameInput : null, box, h("button", {
|
|
8230
|
+
class: "btn btn-primary btn-icon",
|
|
8231
|
+
type: "submit",
|
|
8232
|
+
"aria-label": this.t("sendReply"),
|
|
8233
|
+
title: this.t("sendReply"),
|
|
8234
|
+
disabled: Boolean(card.sendingReply)
|
|
8235
|
+
}, icon(ICONS.send))));
|
|
6350
8236
|
}
|
|
6351
8237
|
positionCard() {
|
|
6352
8238
|
const el = this.uiLayer.querySelector("[data-card]");
|
|
@@ -6354,11 +8240,7 @@ var NuniWidget = class {
|
|
|
6354
8240
|
let point = null;
|
|
6355
8241
|
if (this.card?.kind === "composer") {
|
|
6356
8242
|
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
|
-
};
|
|
8243
|
+
point = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
6362
8244
|
} else if (this.card?.kind === "thread") {
|
|
6363
8245
|
const comment = this.comments.find((c) => this.card?.kind === "thread" && c._id === this.card.id);
|
|
6364
8246
|
point = comment ? this.pinPositions.get(comment._id) ?? this.pinPoint(comment) : null;
|
|
@@ -6374,8 +8256,9 @@ var NuniWidget = class {
|
|
|
6374
8256
|
top = point.y - 28;
|
|
6375
8257
|
if (left + width > vw - 12) left = point.x - width - 12;
|
|
6376
8258
|
} else {
|
|
6377
|
-
|
|
6378
|
-
|
|
8259
|
+
const { position } = this.config;
|
|
8260
|
+
left = position.endsWith("left") ? 16 : vw - width - 16;
|
|
8261
|
+
top = position.startsWith("top") ? 72 : vh - height - 72;
|
|
6379
8262
|
}
|
|
6380
8263
|
left = Math.max(12, Math.min(left, vw - width - 12));
|
|
6381
8264
|
top = Math.max(12, Math.min(top, vh - height - 12));
|
|
@@ -6470,7 +8353,7 @@ function unmount(state) {
|
|
|
6470
8353
|
* down the widget it was created for.
|
|
6471
8354
|
*/
|
|
6472
8355
|
function init(options) {
|
|
6473
|
-
return mount(options, { loadScreenshot: () => import("./screenshot-
|
|
8356
|
+
return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
|
|
6474
8357
|
}
|
|
6475
8358
|
//#endregion
|
|
6476
|
-
export { VERSION, init };
|
|
8359
|
+
export { VERSION, init, EN as messages };
|