@plannotator/ui 0.49.0 → 0.51.0
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/HANDOFF.md +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- package/utils/syntaxTheme.ts +33 -0
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
18
18
|
import { AnnotationType } from "../../types";
|
|
19
19
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
20
|
+
import { elementIdentityForAskAI } from "../../utils/parser";
|
|
20
21
|
import { getIdentity } from "../../utils/identity";
|
|
21
22
|
import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
|
|
22
23
|
import {
|
|
@@ -674,6 +675,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
674
675
|
}));
|
|
675
676
|
}, [hook.draftTargets]);
|
|
676
677
|
|
|
678
|
+
// Ask AI from a pinpoint draft: which element(s) the question is about,
|
|
679
|
+
// in the identity lines the feedback export prints (no outline).
|
|
680
|
+
const askAIElementDetail = useMemo(
|
|
681
|
+
() => elementIdentityForAskAI(hook.draftTargets) || undefined,
|
|
682
|
+
[hook.draftTargets],
|
|
683
|
+
);
|
|
684
|
+
|
|
677
685
|
useEffect(() => {
|
|
678
686
|
function handler(e: MessageEvent<unknown>) {
|
|
679
687
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
@@ -1244,9 +1252,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1244
1252
|
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
1245
1253
|
onSubmit={hook.handleCommentSubmit}
|
|
1246
1254
|
mentionSource={mentionSource}
|
|
1247
|
-
//
|
|
1248
|
-
// the surface's one-click
|
|
1249
|
-
//
|
|
1255
|
+
// A pinpoint click opens this composer directly (never the
|
|
1256
|
+
// selection toolbar), so it carries the surface's one-click 👍.
|
|
1257
|
+
// It also shows when the composer opens from a text selection's
|
|
1258
|
+
// Comment button: a harmless duplicate of the toolbar 👍.
|
|
1250
1259
|
onQuickLookGood={hook.handleCommentLooksGood}
|
|
1251
1260
|
onClose={hook.handleCommentClose}
|
|
1252
1261
|
skillReferences
|
|
@@ -1255,6 +1264,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1255
1264
|
kind: "selection",
|
|
1256
1265
|
label: "Selected HTML",
|
|
1257
1266
|
text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
|
|
1267
|
+
...(askAIElementDetail ? { detail: askAIElementDetail } : {}),
|
|
1258
1268
|
}}
|
|
1259
1269
|
targetChips={targetChips}
|
|
1260
1270
|
onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
|
|
@@ -96,17 +96,89 @@
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
// --- Resize ---
|
|
99
|
-
|
|
99
|
+
// The last child of el whose bottom margin could collapse through el's
|
|
100
|
+
// bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
|
|
101
|
+
// never do, which also skips the bridge's own fixed overlay elements and a
|
|
102
|
+
// trailing script element.
|
|
103
|
+
function lastInFlowChild(el) {
|
|
104
|
+
for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
|
|
105
|
+
var cs = window.getComputedStyle(child);
|
|
106
|
+
if (cs.display === 'none' || cs.display === 'contents') continue;
|
|
107
|
+
if (cs.position === 'absolute' || cs.position === 'fixed') continue;
|
|
108
|
+
if (cs.float && cs.float !== 'none') continue;
|
|
109
|
+
return child;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Height the auto-sized frame needs to show the whole document.
|
|
115
|
+
// Not body.scrollHeight alone: when body has no top padding or border, a
|
|
116
|
+
// first child's top margin collapses THROUGH body and moves body's box down
|
|
117
|
+
// without making it taller (the same holds at the bottom), so the page was
|
|
118
|
+
// cut short by those margins. Not documentElement.scrollHeight either: it
|
|
119
|
+
// never reports less than the frame's own viewport, so the frame could grow
|
|
120
|
+
// but never shrink back, and an html{height:100%} page would pin it at
|
|
121
|
+
// whatever height it had. Instead body's box is placed in document
|
|
122
|
+
// coordinates (its top already includes a collapsed top margin), and the
|
|
123
|
+
// bottom margins that can collapse through body's bottom edge are added by
|
|
124
|
+
// walking the last in-flow child chain. Nothing here reads the frame's own
|
|
125
|
+
// height, so the measure cannot feed back on itself.
|
|
126
|
+
function measureContentHeight() {
|
|
127
|
+
var body = document.body;
|
|
128
|
+
var scrollY = window.pageYOffset || 0;
|
|
129
|
+
var bodyRect = body.getBoundingClientRect();
|
|
130
|
+
var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
|
|
131
|
+
var el = body;
|
|
132
|
+
for (var depth = 0; el && depth < 64; depth++) {
|
|
133
|
+
var cs = window.getComputedStyle(el);
|
|
134
|
+
var marginBottom = parseFloat(cs.marginBottom) || 0;
|
|
135
|
+
if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
|
|
136
|
+
// A child's bottom margin only collapses through a plain block parent
|
|
137
|
+
// with no bottom padding or border that does not clip (flex/grid
|
|
138
|
+
// containers, flow-root and scroll containers keep it inside).
|
|
139
|
+
if (cs.display !== 'block' && cs.display !== 'list-item') break;
|
|
140
|
+
if (cs.overflowY && cs.overflowY !== 'visible') break;
|
|
141
|
+
if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
|
|
142
|
+
el = lastInFlowChild(el);
|
|
143
|
+
}
|
|
144
|
+
var rootStyle = window.getComputedStyle(document.documentElement);
|
|
145
|
+
bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
|
|
146
|
+
return Math.max(0, Math.ceil(bottom));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Feedback guard. Content sized by the frame itself (html,body{height:100%},
|
|
150
|
+
// min-height:100vh, a 100vh section) plus anything around it (a collapsed
|
|
151
|
+
// margin, body's own margin) always measures taller than the frame, so every
|
|
152
|
+
// grow would ask for another: the frame would grow forever. The first
|
|
153
|
+
// measure after the parent applied a GROW we asked for decides: if the page
|
|
154
|
+
// still overflows by at least as much as it did before the grow, it grew
|
|
155
|
+
// with the frame, and the frame stays where it is. Later content changes
|
|
156
|
+
// post as usual (and re-run the check on their own grow). Pure, so the
|
|
157
|
+
// test can drive it: returns the height to post, or null for none.
|
|
158
|
+
function nextResizeHeight(state, h, viewport) {
|
|
159
|
+
var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
|
|
160
|
+
if (applied) state.awaiting = false;
|
|
161
|
+
if (h === state.last) return null;
|
|
162
|
+
if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
|
|
163
|
+
state.last = h; // held: report nothing until the content measures differently
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
state.excess = h - viewport;
|
|
167
|
+
state.awaiting = true;
|
|
168
|
+
state.last = h;
|
|
169
|
+
return h;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
var resizeState = { last: 0, excess: 0, awaiting: false };
|
|
100
173
|
function postResize() {
|
|
101
174
|
if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
|
|
102
175
|
if (!document.body) return;
|
|
103
|
-
var h =
|
|
104
|
-
if (h !==
|
|
105
|
-
lastHeight = h;
|
|
106
|
-
postToParent({ type: PREFIX + 'resize', height: h });
|
|
107
|
-
}
|
|
176
|
+
var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
|
|
177
|
+
if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
|
|
108
178
|
}
|
|
109
179
|
window.addEventListener('load', postResize);
|
|
180
|
+
// The frame resizing is what tells the guard its last grow was applied.
|
|
181
|
+
window.addEventListener('resize', postResize, { passive: true });
|
|
110
182
|
|
|
111
183
|
// --- Selection ---
|
|
112
184
|
var pendingSelection = null;
|
|
@@ -908,6 +980,10 @@
|
|
|
908
980
|
|
|
909
981
|
// Floating label naming the element under the cursor (like the markdown overlay).
|
|
910
982
|
var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
|
|
983
|
+
// Media kinds name themselves only when the page gives no accessible name:
|
|
984
|
+
// unlike the semantic tags above, an aria-label on a <video> is a better
|
|
985
|
+
// label than "Video", and it keeps winning exactly as it did before.
|
|
986
|
+
var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
|
|
911
987
|
|
|
912
988
|
var MAX_HOVER_LABEL = 40;
|
|
913
989
|
function truncateLabel(text) {
|
|
@@ -943,6 +1019,8 @@
|
|
|
943
1019
|
if (known) return known;
|
|
944
1020
|
var aria = el.getAttribute && el.getAttribute('aria-label');
|
|
945
1021
|
if (aria && aria.trim()) return truncateLabel(aria.trim());
|
|
1022
|
+
var media = MEDIA_PINPOINT_LABELS[el.tagName];
|
|
1023
|
+
if (media) return media;
|
|
946
1024
|
var role = el.getAttribute && el.getAttribute('role');
|
|
947
1025
|
if (role && role.trim()) return truncateLabel(role.trim());
|
|
948
1026
|
var tokens = meaningfulClassTokens(el);
|
|
@@ -2608,6 +2686,25 @@
|
|
|
2608
2686
|
function ctxScrubUrl(value) {
|
|
2609
2687
|
var v = String(value).trim();
|
|
2610
2688
|
if (/^javascript:/i.test(v)) return null;
|
|
2689
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
2690
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
2691
|
+
// means nothing outside this session; the path after it is the author's
|
|
2692
|
+
// own, relative to the document, which is what an agent can find.
|
|
2693
|
+
if (!LIVE) {
|
|
2694
|
+
var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
|
|
2695
|
+
if (asset && asset[1]) {
|
|
2696
|
+
// The route percent-encodes each segment; show the name the author
|
|
2697
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
2698
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
2699
|
+
// the raw text.
|
|
2700
|
+
var authored = asset[1];
|
|
2701
|
+
try {
|
|
2702
|
+
var decoded = decodeURIComponent(authored);
|
|
2703
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
2704
|
+
} catch (ex) {}
|
|
2705
|
+
v = authored + v.slice(asset[0].length);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2611
2708
|
if (/^data:/i.test(v)) {
|
|
2612
2709
|
var comma = v.indexOf(',');
|
|
2613
2710
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -2917,7 +3014,8 @@
|
|
|
2917
3014
|
}
|
|
2918
3015
|
|
|
2919
3016
|
// Shed order when over budget: the fields an agent can live without first.
|
|
2920
|
-
|
|
3017
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3018
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
2921
3019
|
|
|
2922
3020
|
function buildElementContext(el, maxBytes) {
|
|
2923
3021
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -2944,6 +3042,12 @@
|
|
|
2944
3042
|
if (heading) context.heading = heading;
|
|
2945
3043
|
var component = ctxComponent(el);
|
|
2946
3044
|
if (component) context.component = component;
|
|
3045
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3046
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3047
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3048
|
+
// even when the element has no usable src attribute.
|
|
3049
|
+
var sourceName = descSourceName(el);
|
|
3050
|
+
if (sourceName) context.sourceName = sourceName;
|
|
2947
3051
|
if (LIVE) {
|
|
2948
3052
|
context.page = { url: currentPageUrl() };
|
|
2949
3053
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3056,11 +3160,95 @@
|
|
|
3056
3160
|
}
|
|
3057
3161
|
}
|
|
3058
3162
|
|
|
3059
|
-
//
|
|
3060
|
-
//
|
|
3163
|
+
// --- Text-less element descriptions ---
|
|
3164
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3165
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3166
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3167
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3168
|
+
// file name. The source goes through the same scrub the element context
|
|
3169
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3170
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3171
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3172
|
+
var DESC_MAX_NAME = 80;
|
|
3173
|
+
var DESC_MAX_FILE = 60;
|
|
3174
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3175
|
+
|
|
3176
|
+
function descClean(value, max) {
|
|
3177
|
+
return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3181
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3182
|
+
// taken up to whitespace instead.
|
|
3183
|
+
function descFirstSrcsetUrl(value) {
|
|
3184
|
+
var srcset = String(value || '').trim();
|
|
3185
|
+
if (!srcset) return '';
|
|
3186
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
function descRawSource(el) {
|
|
3190
|
+
var tag = el.tagName;
|
|
3191
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3192
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3193
|
+
var src = '';
|
|
3194
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3195
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3196
|
+
else return '';
|
|
3197
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3198
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3199
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3200
|
+
if (!src && tag === 'IMG') {
|
|
3201
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3202
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3203
|
+
// no source at all; the first <source> candidate names the file.
|
|
3204
|
+
var picture = el.parentElement;
|
|
3205
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3206
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3207
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3211
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3212
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3213
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3214
|
+
}
|
|
3215
|
+
return src;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// The file name a source points at: "team.jpg" for
|
|
3219
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3220
|
+
function descSourceName(el) {
|
|
3221
|
+
var src = descRawSource(el);
|
|
3222
|
+
if (!src) return '';
|
|
3223
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3224
|
+
if (!scrubbed) return '';
|
|
3225
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3226
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3227
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3228
|
+
}
|
|
3229
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3230
|
+
var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
|
|
3231
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3232
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3233
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3234
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function describeTextlessElement(el, label) {
|
|
3238
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3239
|
+
var name = ctxName(el);
|
|
3240
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3241
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3242
|
+
var file = descSourceName(el);
|
|
3243
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3248
|
+
// element's own text, or for a text-less element its description.
|
|
3061
3249
|
function elementTargetText(el, label) {
|
|
3062
3250
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3063
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3251
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3064
3252
|
return text;
|
|
3065
3253
|
}
|
|
3066
3254
|
|
|
@@ -3299,10 +3487,10 @@
|
|
|
3299
3487
|
if (!posted) clearPendingPin();
|
|
3300
3488
|
return posted;
|
|
3301
3489
|
}
|
|
3302
|
-
|
|
3303
|
-
//
|
|
3304
|
-
//
|
|
3305
|
-
|
|
3490
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3491
|
+
// containers) are still annotatable: describe the element instead of
|
|
3492
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3493
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3306
3494
|
var r = el.getBoundingClientRect();
|
|
3307
3495
|
pendingSelection = { element: true };
|
|
3308
3496
|
pendingRange = null;
|
|
@@ -4898,11 +5086,16 @@
|
|
|
4898
5086
|
|
|
4899
5087
|
function onReady() {
|
|
4900
5088
|
if (typeof ResizeObserver !== 'undefined' && document.body) {
|
|
4901
|
-
new ResizeObserver(function() {
|
|
5089
|
+
var resizeObserver = new ResizeObserver(function() {
|
|
4902
5090
|
postResize();
|
|
4903
5091
|
scheduleVimUiUpdate();
|
|
4904
5092
|
schedulePinpointReconcile();
|
|
4905
|
-
})
|
|
5093
|
+
});
|
|
5094
|
+
resizeObserver.observe(document.body);
|
|
5095
|
+
// A collapsed margin changing moves body without resizing it; the root
|
|
5096
|
+
// box does resize then. (An html{height:100%} root resizes with the
|
|
5097
|
+
// frame instead, which re-measures the same height and posts nothing.)
|
|
5098
|
+
resizeObserver.observe(document.documentElement);
|
|
4906
5099
|
}
|
|
4907
5100
|
watchPageMutations();
|
|
4908
5101
|
// Armed is the default on both surfaces, and live sessions default to
|
|
@@ -332,17 +332,89 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
332
332
|
});
|
|
333
333
|
|
|
334
334
|
// --- Resize ---
|
|
335
|
-
|
|
335
|
+
// The last child of el whose bottom margin could collapse through el's
|
|
336
|
+
// bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
|
|
337
|
+
// never do, which also skips the bridge's own fixed overlay elements and a
|
|
338
|
+
// trailing script element.
|
|
339
|
+
function lastInFlowChild(el) {
|
|
340
|
+
for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
|
|
341
|
+
var cs = window.getComputedStyle(child);
|
|
342
|
+
if (cs.display === 'none' || cs.display === 'contents') continue;
|
|
343
|
+
if (cs.position === 'absolute' || cs.position === 'fixed') continue;
|
|
344
|
+
if (cs.float && cs.float !== 'none') continue;
|
|
345
|
+
return child;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Height the auto-sized frame needs to show the whole document.
|
|
351
|
+
// Not body.scrollHeight alone: when body has no top padding or border, a
|
|
352
|
+
// first child's top margin collapses THROUGH body and moves body's box down
|
|
353
|
+
// without making it taller (the same holds at the bottom), so the page was
|
|
354
|
+
// cut short by those margins. Not documentElement.scrollHeight either: it
|
|
355
|
+
// never reports less than the frame's own viewport, so the frame could grow
|
|
356
|
+
// but never shrink back, and an html{height:100%} page would pin it at
|
|
357
|
+
// whatever height it had. Instead body's box is placed in document
|
|
358
|
+
// coordinates (its top already includes a collapsed top margin), and the
|
|
359
|
+
// bottom margins that can collapse through body's bottom edge are added by
|
|
360
|
+
// walking the last in-flow child chain. Nothing here reads the frame's own
|
|
361
|
+
// height, so the measure cannot feed back on itself.
|
|
362
|
+
function measureContentHeight() {
|
|
363
|
+
var body = document.body;
|
|
364
|
+
var scrollY = window.pageYOffset || 0;
|
|
365
|
+
var bodyRect = body.getBoundingClientRect();
|
|
366
|
+
var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
|
|
367
|
+
var el = body;
|
|
368
|
+
for (var depth = 0; el && depth < 64; depth++) {
|
|
369
|
+
var cs = window.getComputedStyle(el);
|
|
370
|
+
var marginBottom = parseFloat(cs.marginBottom) || 0;
|
|
371
|
+
if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
|
|
372
|
+
// A child's bottom margin only collapses through a plain block parent
|
|
373
|
+
// with no bottom padding or border that does not clip (flex/grid
|
|
374
|
+
// containers, flow-root and scroll containers keep it inside).
|
|
375
|
+
if (cs.display !== 'block' && cs.display !== 'list-item') break;
|
|
376
|
+
if (cs.overflowY && cs.overflowY !== 'visible') break;
|
|
377
|
+
if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
|
|
378
|
+
el = lastInFlowChild(el);
|
|
379
|
+
}
|
|
380
|
+
var rootStyle = window.getComputedStyle(document.documentElement);
|
|
381
|
+
bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
|
|
382
|
+
return Math.max(0, Math.ceil(bottom));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Feedback guard. Content sized by the frame itself (html,body{height:100%},
|
|
386
|
+
// min-height:100vh, a 100vh section) plus anything around it (a collapsed
|
|
387
|
+
// margin, body's own margin) always measures taller than the frame, so every
|
|
388
|
+
// grow would ask for another: the frame would grow forever. The first
|
|
389
|
+
// measure after the parent applied a GROW we asked for decides: if the page
|
|
390
|
+
// still overflows by at least as much as it did before the grow, it grew
|
|
391
|
+
// with the frame, and the frame stays where it is. Later content changes
|
|
392
|
+
// post as usual (and re-run the check on their own grow). Pure, so the
|
|
393
|
+
// test can drive it: returns the height to post, or null for none.
|
|
394
|
+
function nextResizeHeight(state, h, viewport) {
|
|
395
|
+
var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
|
|
396
|
+
if (applied) state.awaiting = false;
|
|
397
|
+
if (h === state.last) return null;
|
|
398
|
+
if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
|
|
399
|
+
state.last = h; // held: report nothing until the content measures differently
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
state.excess = h - viewport;
|
|
403
|
+
state.awaiting = true;
|
|
404
|
+
state.last = h;
|
|
405
|
+
return h;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var resizeState = { last: 0, excess: 0, awaiting: false };
|
|
336
409
|
function postResize() {
|
|
337
410
|
if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
|
|
338
411
|
if (!document.body) return;
|
|
339
|
-
var h =
|
|
340
|
-
if (h !==
|
|
341
|
-
lastHeight = h;
|
|
342
|
-
postToParent({ type: PREFIX + 'resize', height: h });
|
|
343
|
-
}
|
|
412
|
+
var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
|
|
413
|
+
if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
|
|
344
414
|
}
|
|
345
415
|
window.addEventListener('load', postResize);
|
|
416
|
+
// The frame resizing is what tells the guard its last grow was applied.
|
|
417
|
+
window.addEventListener('resize', postResize, { passive: true });
|
|
346
418
|
|
|
347
419
|
// --- Selection ---
|
|
348
420
|
var pendingSelection = null;
|
|
@@ -1144,6 +1216,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1144
1216
|
|
|
1145
1217
|
// Floating label naming the element under the cursor (like the markdown overlay).
|
|
1146
1218
|
var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
|
|
1219
|
+
// Media kinds name themselves only when the page gives no accessible name:
|
|
1220
|
+
// unlike the semantic tags above, an aria-label on a <video> is a better
|
|
1221
|
+
// label than "Video", and it keeps winning exactly as it did before.
|
|
1222
|
+
var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
|
|
1147
1223
|
|
|
1148
1224
|
var MAX_HOVER_LABEL = 40;
|
|
1149
1225
|
function truncateLabel(text) {
|
|
@@ -1179,6 +1255,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1179
1255
|
if (known) return known;
|
|
1180
1256
|
var aria = el.getAttribute && el.getAttribute('aria-label');
|
|
1181
1257
|
if (aria && aria.trim()) return truncateLabel(aria.trim());
|
|
1258
|
+
var media = MEDIA_PINPOINT_LABELS[el.tagName];
|
|
1259
|
+
if (media) return media;
|
|
1182
1260
|
var role = el.getAttribute && el.getAttribute('role');
|
|
1183
1261
|
if (role && role.trim()) return truncateLabel(role.trim());
|
|
1184
1262
|
var tokens = meaningfulClassTokens(el);
|
|
@@ -2844,6 +2922,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2844
2922
|
function ctxScrubUrl(value) {
|
|
2845
2923
|
var v = String(value).trim();
|
|
2846
2924
|
if (/^javascript:/i.test(v)) return null;
|
|
2925
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
2926
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
2927
|
+
// means nothing outside this session; the path after it is the author's
|
|
2928
|
+
// own, relative to the document, which is what an agent can find.
|
|
2929
|
+
if (!LIVE) {
|
|
2930
|
+
var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
|
|
2931
|
+
if (asset && asset[1]) {
|
|
2932
|
+
// The route percent-encodes each segment; show the name the author
|
|
2933
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
2934
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
2935
|
+
// the raw text.
|
|
2936
|
+
var authored = asset[1];
|
|
2937
|
+
try {
|
|
2938
|
+
var decoded = decodeURIComponent(authored);
|
|
2939
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
2940
|
+
} catch (ex) {}
|
|
2941
|
+
v = authored + v.slice(asset[0].length);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2847
2944
|
if (/^data:/i.test(v)) {
|
|
2848
2945
|
var comma = v.indexOf(',');
|
|
2849
2946
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -3153,7 +3250,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3153
3250
|
}
|
|
3154
3251
|
|
|
3155
3252
|
// Shed order when over budget: the fields an agent can live without first.
|
|
3156
|
-
|
|
3253
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3254
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
3157
3255
|
|
|
3158
3256
|
function buildElementContext(el, maxBytes) {
|
|
3159
3257
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -3180,6 +3278,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3180
3278
|
if (heading) context.heading = heading;
|
|
3181
3279
|
var component = ctxComponent(el);
|
|
3182
3280
|
if (component) context.component = component;
|
|
3281
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3282
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3283
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3284
|
+
// even when the element has no usable src attribute.
|
|
3285
|
+
var sourceName = descSourceName(el);
|
|
3286
|
+
if (sourceName) context.sourceName = sourceName;
|
|
3183
3287
|
if (LIVE) {
|
|
3184
3288
|
context.page = { url: currentPageUrl() };
|
|
3185
3289
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3292,11 +3396,95 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3292
3396
|
}
|
|
3293
3397
|
}
|
|
3294
3398
|
|
|
3295
|
-
//
|
|
3296
|
-
//
|
|
3399
|
+
// --- Text-less element descriptions ---
|
|
3400
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3401
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3402
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3403
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3404
|
+
// file name. The source goes through the same scrub the element context
|
|
3405
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3406
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3407
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3408
|
+
var DESC_MAX_NAME = 80;
|
|
3409
|
+
var DESC_MAX_FILE = 60;
|
|
3410
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3411
|
+
|
|
3412
|
+
function descClean(value, max) {
|
|
3413
|
+
return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3417
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3418
|
+
// taken up to whitespace instead.
|
|
3419
|
+
function descFirstSrcsetUrl(value) {
|
|
3420
|
+
var srcset = String(value || '').trim();
|
|
3421
|
+
if (!srcset) return '';
|
|
3422
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function descRawSource(el) {
|
|
3426
|
+
var tag = el.tagName;
|
|
3427
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3428
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3429
|
+
var src = '';
|
|
3430
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3431
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3432
|
+
else return '';
|
|
3433
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3434
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3435
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3436
|
+
if (!src && tag === 'IMG') {
|
|
3437
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3438
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3439
|
+
// no source at all; the first <source> candidate names the file.
|
|
3440
|
+
var picture = el.parentElement;
|
|
3441
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3442
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3443
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3447
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3448
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3449
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3450
|
+
}
|
|
3451
|
+
return src;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
// The file name a source points at: "team.jpg" for
|
|
3455
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3456
|
+
function descSourceName(el) {
|
|
3457
|
+
var src = descRawSource(el);
|
|
3458
|
+
if (!src) return '';
|
|
3459
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3460
|
+
if (!scrubbed) return '';
|
|
3461
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3462
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3463
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3464
|
+
}
|
|
3465
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3466
|
+
var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
|
|
3467
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3468
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3469
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3470
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
function describeTextlessElement(el, label) {
|
|
3474
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3475
|
+
var name = ctxName(el);
|
|
3476
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3477
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3478
|
+
var file = descSourceName(el);
|
|
3479
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3480
|
+
return out;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3484
|
+
// element's own text, or for a text-less element its description.
|
|
3297
3485
|
function elementTargetText(el, label) {
|
|
3298
3486
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3299
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3487
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3300
3488
|
return text;
|
|
3301
3489
|
}
|
|
3302
3490
|
|
|
@@ -3535,10 +3723,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3535
3723
|
if (!posted) clearPendingPin();
|
|
3536
3724
|
return posted;
|
|
3537
3725
|
}
|
|
3538
|
-
|
|
3539
|
-
//
|
|
3540
|
-
//
|
|
3541
|
-
|
|
3726
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3727
|
+
// containers) are still annotatable: describe the element instead of
|
|
3728
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3729
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3542
3730
|
var r = el.getBoundingClientRect();
|
|
3543
3731
|
pendingSelection = { element: true };
|
|
3544
3732
|
pendingRange = null;
|
|
@@ -5134,11 +5322,16 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
5134
5322
|
|
|
5135
5323
|
function onReady() {
|
|
5136
5324
|
if (typeof ResizeObserver !== 'undefined' && document.body) {
|
|
5137
|
-
new ResizeObserver(function() {
|
|
5325
|
+
var resizeObserver = new ResizeObserver(function() {
|
|
5138
5326
|
postResize();
|
|
5139
5327
|
scheduleVimUiUpdate();
|
|
5140
5328
|
schedulePinpointReconcile();
|
|
5141
|
-
})
|
|
5329
|
+
});
|
|
5330
|
+
resizeObserver.observe(document.body);
|
|
5331
|
+
// A collapsed margin changing moves body without resizing it; the root
|
|
5332
|
+
// box does resize then. (An html{height:100%} root resizes with the
|
|
5333
|
+
// frame instead, which re-measures the same height and posts nothing.)
|
|
5334
|
+
resizeObserver.observe(document.documentElement);
|
|
5142
5335
|
}
|
|
5143
5336
|
watchPageMutations();
|
|
5144
5337
|
// Armed is the default on both surfaces, and live sessions default to
|