@plannotator/ui 0.53.1 → 0.54.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 +217 -11
- package/README.md +65 -10
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +280 -41
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +47 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
package/utils/platform.ts
CHANGED
|
@@ -14,6 +14,11 @@ export const submitHint = isMac ? '⌘↵' : 'Ctrl+Enter';
|
|
|
14
14
|
* use `modKey` in key hints and shortcut chips.
|
|
15
15
|
*/
|
|
16
16
|
export const modKeyWord = isMac ? 'Cmd' : 'Ctrl';
|
|
17
|
+
/**
|
|
18
|
+
* The primary modifier as a screen reader should say it in a sentence
|
|
19
|
+
* ("hold Command and scroll"), for live-region text.
|
|
20
|
+
*/
|
|
21
|
+
export const modKeySpoken = isMac ? 'Command' : 'Control';
|
|
17
22
|
/**
|
|
18
23
|
* The primary modifier's own `KeyboardEvent.key` name, for features that arm
|
|
19
24
|
* on the modifier being HELD rather than on a chord. Pair with `isModKeyHeld`:
|
|
@@ -25,3 +30,44 @@ export function isModKeyHeld(event: { metaKey: boolean; ctrlKey: boolean }): boo
|
|
|
25
30
|
return isMac ? event.metaKey : event.ctrlKey;
|
|
26
31
|
}
|
|
27
32
|
export const isWindows = typeof navigator !== 'undefined' && /^Win/.test(navigator.platform);
|
|
33
|
+
|
|
34
|
+
/** What `rootPlatform` reads off `navigator` (User-Agent Client Hints are Chromium-only). */
|
|
35
|
+
export interface RootPlatformNavigator {
|
|
36
|
+
userAgent?: string;
|
|
37
|
+
platform?: string;
|
|
38
|
+
userAgentData?: { platform?: string } | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RootPlatform = 'apple' | 'windows' | 'android' | 'chromeos' | 'linux' | 'other';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The operating system, coarse: what the page's CSS may need to know. Android
|
|
45
|
+
* and ChromeOS are told apart from desktop Linux (their user agents say
|
|
46
|
+
* "Linux" too); iPadOS in desktop mode says "Macintosh" and counts as apple.
|
|
47
|
+
*/
|
|
48
|
+
export function rootPlatform(nav: RootPlatformNavigator | undefined | null): RootPlatform {
|
|
49
|
+
if (!nav) return 'other';
|
|
50
|
+
const s = `${nav.userAgentData?.platform ?? ''} ${nav.platform ?? ''} ${nav.userAgent ?? ''}`;
|
|
51
|
+
if (/Android/i.test(s)) return 'android';
|
|
52
|
+
if (/CrOS|Chrome OS/i.test(s)) return 'chromeos';
|
|
53
|
+
if (/Mac|iPhone|iPad|iPod/i.test(s)) return 'apple';
|
|
54
|
+
if (/Win/i.test(s)) return 'windows';
|
|
55
|
+
if (/Linux|X11|BSD/i.test(s)) return 'linux';
|
|
56
|
+
return 'other';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Put `pn-os-<platform>` on <html>, once. theme.css scopes platform-specific
|
|
61
|
+
* rules to it: on desktop Linux the monospace fallback (Ubuntu Mono) draws
|
|
62
|
+
* `ss02` as superscript digits, so only there are Inter's feature tags kept
|
|
63
|
+
* off other fonts (#1961). ThemeProvider calls this synchronously in render,
|
|
64
|
+
* with the theme classes, so the class is on the root before the first paint.
|
|
65
|
+
* The marketing site and the guides.show landing page set the same class
|
|
66
|
+
* from an inline script (keep their patterns in step with this one).
|
|
67
|
+
*/
|
|
68
|
+
export function markRootPlatform(doc: Document | undefined = typeof document !== 'undefined' ? document : undefined): void {
|
|
69
|
+
if (!doc) return;
|
|
70
|
+
const root = doc.documentElement;
|
|
71
|
+
for (const cls of Array.from(root.classList)) if (cls.startsWith('pn-os-')) return;
|
|
72
|
+
root.classList.add(`pn-os-${rootPlatform(typeof navigator !== 'undefined' ? navigator : null)}`);
|
|
73
|
+
}
|
package/utils/refCard.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an issue / PR / MR hover card's data comes from (`components/RefHoverCard`).
|
|
3
|
+
*
|
|
4
|
+
* A host seam in the `configurePlannotatorUI` style: the default is NO
|
|
5
|
+
* transport, so a host that installs none (every `@plannotator/ui` consumer
|
|
6
|
+
* that does not opt in) draws no card and makes no request: byte for byte as
|
|
7
|
+
* before. A host that can fetch installs one with `setRefCardTransport` or
|
|
8
|
+
* `configurePlannotatorUI({ refCardTransport })`: the Inbox over its
|
|
9
|
+
* `/api/inbox/ref` route, plan review, annotate and code review over
|
|
10
|
+
* `GET /api/ref` (`refCardRouteTransport` below).
|
|
11
|
+
*
|
|
12
|
+
* The client keeps answers for `REF_CARD_CLIENT_FRESH_MS` per ref and shares
|
|
13
|
+
* one request between hovers of the same ref. The server keeps its own cache;
|
|
14
|
+
* this one only spares the round trip while a person moves between links.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { forgeRefKey, parseForgeRefUrl, type ForgeRef, type ForgeRefCardResult } from '@plannotator/core/forge-ref-card';
|
|
18
|
+
|
|
19
|
+
export type { ForgeRef, ForgeRefCardResult } from '@plannotator/core/forge-ref-card';
|
|
20
|
+
|
|
21
|
+
/** Fetch the card for a link. Resolve null when the host does not read that link. */
|
|
22
|
+
export type RefCardTransport = (href: string, ref: ForgeRef, signal: AbortSignal) => Promise<ForgeRefCardResult | null>;
|
|
23
|
+
|
|
24
|
+
let transport: RefCardTransport | null = null;
|
|
25
|
+
|
|
26
|
+
export function setRefCardTransport(next: RefCardTransport | null): void {
|
|
27
|
+
transport = next;
|
|
28
|
+
cache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getRefCardTransport(): RefCardTransport | null {
|
|
32
|
+
return transport;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A transport over a server route answering like Plannotator's `GET /api/ref`
|
|
37
|
+
* (and the Inbox's `/api/inbox/ref`): `?url=<link>` → `{ ok, data | error,
|
|
38
|
+
* fetchedAt }`. A 404 means the route is not there (an older server) or is off
|
|
39
|
+
* (a session other devices can reach): the transport then stops asking for
|
|
40
|
+
* the life of the page and answers null. Any other failure answers null.
|
|
41
|
+
*/
|
|
42
|
+
export function refCardRouteTransport(route: string): RefCardTransport {
|
|
43
|
+
let off = false;
|
|
44
|
+
return async (href, _ref, signal) => {
|
|
45
|
+
if (off) return null;
|
|
46
|
+
const res = await fetch(`${route}?url=${encodeURIComponent(href)}`, { signal, headers: { accept: 'application/json' } });
|
|
47
|
+
if (res.status === 404) {
|
|
48
|
+
off = true;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) return null;
|
|
52
|
+
const body = (await res.json()) as unknown;
|
|
53
|
+
return body && typeof body === 'object' && typeof (body as { ok?: unknown }).ok === 'boolean' ? (body as ForgeRefCardResult) : null;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const REF_CARD_CLIENT_FRESH_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
const cache = new Map<string, { at: number; promise: Promise<ForgeRefCardResult | null> }>();
|
|
60
|
+
|
|
61
|
+
/** The ref a link names, when a transport is installed to fetch it; else null (no card). */
|
|
62
|
+
export function refCardTarget(href: string | null | undefined): ForgeRef | null {
|
|
63
|
+
if (!transport || !href) return null;
|
|
64
|
+
return parseForgeRefUrl(href);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The card for a link, through the installed transport (shared and cached per ref). */
|
|
68
|
+
export function loadRefCard(href: string, ref: ForgeRef): Promise<ForgeRefCardResult | null> {
|
|
69
|
+
const t = transport;
|
|
70
|
+
if (!t) return Promise.resolve(null);
|
|
71
|
+
const key = forgeRefKey(ref);
|
|
72
|
+
const hit = cache.get(key);
|
|
73
|
+
if (hit && Date.now() - hit.at < REF_CARD_CLIENT_FRESH_MS) return hit.promise;
|
|
74
|
+
// Never aborted by one hover leaving: another hover may share it, and the server caches it anyway.
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const promise = t(href, ref, controller.signal).catch(() => null);
|
|
77
|
+
cache.set(key, { at: Date.now(), promise });
|
|
78
|
+
void promise.then((r) => {
|
|
79
|
+
if (!r) cache.delete(key);
|
|
80
|
+
});
|
|
81
|
+
return promise;
|
|
82
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { copyTextToClipboard } from './clipboard';
|
|
2
|
+
import { restoreClipboardLists } from './richClipboardLists';
|
|
3
|
+
|
|
4
|
+
/** Cap on markup carried across the HTML-viewer bridge (and on its range text). */
|
|
5
|
+
export const MAX_RICH_CLIPBOARD_HTML_LENGTH = 256 * 1024;
|
|
6
|
+
/** Cap on the sanitized HTML put on the clipboard; past it the copy is plain text. */
|
|
7
|
+
export const MAX_RICH_CLIPBOARD_OUTPUT_LENGTH = 512 * 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the toolbar's Copy button copies when rich copy is on, built when it is
|
|
11
|
+
* pressed: sanitized HTML plus its plain text (`html: ''` when no markup came
|
|
12
|
+
* back), the selection marked too large for rich text, or null when nothing
|
|
13
|
+
* could be built. `truncated` marks `text` as not the whole selection.
|
|
14
|
+
*/
|
|
15
|
+
export type RichCopySource =
|
|
16
|
+
| { html: string; text: string; truncated?: boolean }
|
|
17
|
+
| { tooLarge: true; text: string; truncated?: boolean }
|
|
18
|
+
| null;
|
|
19
|
+
|
|
20
|
+
/** What the button actually put on the clipboard, for its tooltip. */
|
|
21
|
+
export interface RichCopyOutcome {
|
|
22
|
+
copied: 'rich' | 'plain';
|
|
23
|
+
/** Why plain: no rich clipboard here, too large for rich text, or no markup came back. */
|
|
24
|
+
reason?: 'unavailable' | 'too-large' | 'no-markup';
|
|
25
|
+
/** Set when the copied text is cut: its length in characters. */
|
|
26
|
+
truncatedTo?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The button's tooltip after a copy: says exactly what went on the clipboard. */
|
|
30
|
+
export function richCopyOutcomeLabel(outcome: RichCopyOutcome): string {
|
|
31
|
+
const notes: string[] = [];
|
|
32
|
+
if (outcome.copied === 'plain') {
|
|
33
|
+
notes.push(
|
|
34
|
+
outcome.reason === 'too-large' ? 'too large for rich text'
|
|
35
|
+
: outcome.reason === 'no-markup' ? 'formatting not available'
|
|
36
|
+
: 'rich clipboard unavailable',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (outcome.truncatedTo !== undefined) {
|
|
40
|
+
notes.push(`first ${outcome.truncatedTo.toLocaleString('en-US')} characters only`);
|
|
41
|
+
}
|
|
42
|
+
const base = outcome.copied === 'rich' ? 'Copied as rich text' : 'Copied as plain text';
|
|
43
|
+
return notes.length > 0 ? `${base} (${notes.join('; ')})` : base;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ALLOWED_TAGS = new Set([
|
|
47
|
+
'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3',
|
|
48
|
+
'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 's', 'span', 'strong',
|
|
49
|
+
'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const DROP_WITH_CONTENT = new Set([
|
|
53
|
+
'AUDIO', 'BUTTON', 'CANVAS', 'EMBED', 'FORM', 'IFRAME', 'INPUT', 'OBJECT',
|
|
54
|
+
'OPTION', 'SCRIPT', 'SELECT', 'STYLE', 'SVG', 'TEMPLATE', 'TEXTAREA', 'VIDEO',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const DROP_SELECTORS = [
|
|
58
|
+
'.annotation-exclude',
|
|
59
|
+
'.annotation-toolbar',
|
|
60
|
+
'[data-comment-popover="true"]',
|
|
61
|
+
'[data-floating-picker="true"]',
|
|
62
|
+
'[data-print-hide]',
|
|
63
|
+
'button',
|
|
64
|
+
].join(',');
|
|
65
|
+
|
|
66
|
+
function safeIntegerAttribute(element: Element, name: string, minimum: number): string | null {
|
|
67
|
+
const value = element.getAttribute(name);
|
|
68
|
+
if (!value || !/^-?\d+$/.test(value)) return null;
|
|
69
|
+
const number = Number(value);
|
|
70
|
+
return Number.isSafeInteger(number) && number >= minimum ? String(number) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A hostname that names this machine, whatever its spelling (URL-parsed). */
|
|
74
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
75
|
+
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
76
|
+
return host === 'localhost'
|
|
77
|
+
|| host.endsWith('.localhost')
|
|
78
|
+
|| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
|
|
79
|
+
|| host === '0.0.0.0'
|
|
80
|
+
|| host === '[::1]'
|
|
81
|
+
|| host === '[::]'
|
|
82
|
+
// IPv4-mapped and IPv4-compatible loopback, as the URL parser serializes
|
|
83
|
+
// them ([::ffff:7f00:1], [::7f00:1]).
|
|
84
|
+
|| /^\[::(ffff:)?7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeLinkHref(href: string): string | null {
|
|
88
|
+
try {
|
|
89
|
+
const baseUrl = new URL(document.baseURI);
|
|
90
|
+
const url = new URL(href, baseUrl);
|
|
91
|
+
if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) return null;
|
|
92
|
+
// A link to this session's own server or to this machine is dead once the
|
|
93
|
+
// session ends, and some carry a per-document access token
|
|
94
|
+
// (/api/html-assets/<token>/) or a local path: never exported. The link
|
|
95
|
+
// text stays.
|
|
96
|
+
if (
|
|
97
|
+
(url.protocol === 'http:' || url.protocol === 'https:')
|
|
98
|
+
&& (url.origin === baseUrl.origin || isLoopbackHostname(url.hostname))
|
|
99
|
+
) return null;
|
|
100
|
+
url.username = '';
|
|
101
|
+
url.password = '';
|
|
102
|
+
return url.href;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendSanitizedNode(source: Node, target: Node): void {
|
|
109
|
+
if (source.nodeType === Node.TEXT_NODE) {
|
|
110
|
+
target.appendChild(document.createTextNode(source.textContent ?? ''));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!(source instanceof Element)) return;
|
|
114
|
+
if (DROP_WITH_CONTENT.has(source.tagName.toUpperCase()) || source.matches(DROP_SELECTORS)) return;
|
|
115
|
+
|
|
116
|
+
const tag = source.tagName.toLowerCase();
|
|
117
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
118
|
+
source.childNodes.forEach((child) => appendSanitizedNode(child, target));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const clean = document.createElement(tag);
|
|
123
|
+
const element = source;
|
|
124
|
+
if (tag === 'a') {
|
|
125
|
+
const href = element.getAttribute('href');
|
|
126
|
+
const safeHref = href ? safeLinkHref(href) : null;
|
|
127
|
+
if (safeHref) clean.setAttribute('href', safeHref);
|
|
128
|
+
}
|
|
129
|
+
if (tag === 'ol') {
|
|
130
|
+
const start = safeIntegerAttribute(element, 'start', -2147483648);
|
|
131
|
+
if (start) clean.setAttribute('start', start);
|
|
132
|
+
}
|
|
133
|
+
if (tag === 'li') {
|
|
134
|
+
const value = safeIntegerAttribute(element, 'value', -2147483648);
|
|
135
|
+
if (value) clean.setAttribute('value', value);
|
|
136
|
+
}
|
|
137
|
+
if (tag === 'td' || tag === 'th') {
|
|
138
|
+
for (const name of ['colspan', 'rowspan']) {
|
|
139
|
+
const span = safeIntegerAttribute(element, name, 1);
|
|
140
|
+
if (span && Number(span) <= 1000) clean.setAttribute(name, span);
|
|
141
|
+
}
|
|
142
|
+
const scope = element.getAttribute('scope');
|
|
143
|
+
if (scope && ['row', 'col', 'rowgroup', 'colgroup'].includes(scope)) {
|
|
144
|
+
clean.setAttribute('scope', scope);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (tag === 'ol' || tag === 'ul' || tag === 'li') {
|
|
148
|
+
const dir = element.getAttribute('dir');
|
|
149
|
+
if (dir && ['ltr', 'rtl', 'auto'].includes(dir)) clean.setAttribute('dir', dir);
|
|
150
|
+
}
|
|
151
|
+
source.childNodes.forEach((child) => appendSanitizedNode(child, clean));
|
|
152
|
+
target.appendChild(clean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sanitizeFragment(fragment: ParentNode): string {
|
|
156
|
+
const clean = document.createElement('div');
|
|
157
|
+
fragment.childNodes.forEach((node) => appendSanitizedNode(node, clean));
|
|
158
|
+
return clean.innerHTML;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Snapshot selected DOM as safe, theme-free rich clipboard HTML. Null when the
|
|
163
|
+
* selection is too large for rich text (its text or the sanitized HTML past
|
|
164
|
+
* MAX_RICH_CLIPBOARD_OUTPUT_LENGTH).
|
|
165
|
+
*/
|
|
166
|
+
export function selectionRangeToHtml(range: Range): string | null {
|
|
167
|
+
if (range.toString().length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) return null;
|
|
168
|
+
const fragment = range.cloneContents();
|
|
169
|
+
// cloneContents omits the common ancestor, including formatting when both
|
|
170
|
+
// endpoints are inside the same heading, link, or bold text node.
|
|
171
|
+
let ancestor = range.commonAncestorContainer instanceof Element
|
|
172
|
+
? range.commonAncestorContainer
|
|
173
|
+
: range.commonAncestorContainer.parentElement;
|
|
174
|
+
while (ancestor && ancestor !== document.body) {
|
|
175
|
+
if (ancestor.matches('a,b,blockquote,code,del,em,h1,h2,h3,h4,h5,h6,i,li,ol,p,pre,s,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,u,ul,[data-rich-list-level]')) {
|
|
176
|
+
const wrapper = ancestor.cloneNode(false);
|
|
177
|
+
while (fragment.firstChild) wrapper.appendChild(fragment.firstChild);
|
|
178
|
+
fragment.appendChild(wrapper);
|
|
179
|
+
}
|
|
180
|
+
ancestor = ancestor.parentElement;
|
|
181
|
+
}
|
|
182
|
+
restoreClipboardLists(fragment);
|
|
183
|
+
const html = sanitizeFragment(fragment);
|
|
184
|
+
return html.length <= MAX_RICH_CLIPBOARD_OUTPUT_LENGTH ? html : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The button's source for a live range, built at press time. Never throws. */
|
|
188
|
+
export function richCopySourceFromRange(range: Range, text: string): RichCopySource {
|
|
189
|
+
try {
|
|
190
|
+
const html = selectionRangeToHtml(range);
|
|
191
|
+
if (html === null) return { tooLarge: true, text };
|
|
192
|
+
return html ? { html, text } : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Sanitize HTML received across the HTML-viewer bridge before clipboard use. */
|
|
199
|
+
export function sanitizeRichHtml(html: string): string | null {
|
|
200
|
+
if (!html || html.length > MAX_RICH_CLIPBOARD_HTML_LENGTH) return null;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
203
|
+
return sanitizeFragment(parsed.body);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function copyRichTextWithFallback(html: string, text: string): boolean {
|
|
210
|
+
const activeElement = document.activeElement instanceof HTMLElement
|
|
211
|
+
? document.activeElement
|
|
212
|
+
: null;
|
|
213
|
+
const selection = window.getSelection();
|
|
214
|
+
const savedRanges = selection
|
|
215
|
+
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
|
216
|
+
selection.getRangeAt(index).cloneRange())
|
|
217
|
+
: [];
|
|
218
|
+
let copied = false;
|
|
219
|
+
|
|
220
|
+
// Only this button's own execCommand('copy') passes through here: the
|
|
221
|
+
// listener exists for that one synchronous call, at window capture, and
|
|
222
|
+
// stops the event so no page copy handler (Viewer's plain-text one) runs.
|
|
223
|
+
const handleCopy = (event: ClipboardEvent) => {
|
|
224
|
+
event.stopImmediatePropagation();
|
|
225
|
+
const clipboardData = event.clipboardData;
|
|
226
|
+
if (!clipboardData) return;
|
|
227
|
+
try {
|
|
228
|
+
clipboardData.setData('text/html', html);
|
|
229
|
+
clipboardData.setData('text/plain', text);
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
copied = true;
|
|
232
|
+
} catch {
|
|
233
|
+
copied = false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
window.addEventListener('copy', handleCopy, true);
|
|
238
|
+
try {
|
|
239
|
+
copied = document.execCommand('copy') && copied;
|
|
240
|
+
} catch {
|
|
241
|
+
copied = false;
|
|
242
|
+
} finally {
|
|
243
|
+
window.removeEventListener('copy', handleCopy, true);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (activeElement?.isConnected) {
|
|
247
|
+
activeElement.focus({ preventScroll: true });
|
|
248
|
+
}
|
|
249
|
+
if (selection && savedRanges.length > 0) {
|
|
250
|
+
selection.removeAllRanges();
|
|
251
|
+
savedRanges.forEach((range) => selection.addRange(range));
|
|
252
|
+
}
|
|
253
|
+
return copied;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 'too-large': plain text was copied because the HTML is past the output cap. */
|
|
257
|
+
export type RichClipboardResult = 'rich' | 'plain' | 'too-large' | false;
|
|
258
|
+
|
|
259
|
+
/** Prefer both MIME types; report an honest plain fallback when the host is text-only. */
|
|
260
|
+
export async function copyRichTextToClipboard(html: string, text: string): Promise<RichClipboardResult> {
|
|
261
|
+
if (html.length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) {
|
|
262
|
+
return await copyTextToClipboard(text) ? 'too-large' : false;
|
|
263
|
+
}
|
|
264
|
+
if (!html) {
|
|
265
|
+
return await copyTextToClipboard(text) ? 'plain' : false;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
|
269
|
+
const item = new ClipboardItem({
|
|
270
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
271
|
+
'text/plain': new Blob([text], { type: 'text/plain' }),
|
|
272
|
+
});
|
|
273
|
+
await navigator.clipboard.write([item]);
|
|
274
|
+
return 'rich';
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Fall back only if the legacy copy event can set both MIME types.
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
if (copyRichTextWithFallback(html, text)) return 'rich';
|
|
281
|
+
} catch {
|
|
282
|
+
// Try the host's text-only clipboard below.
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
if (navigator.clipboard?.writeText) {
|
|
286
|
+
await navigator.clipboard.writeText(text);
|
|
287
|
+
return 'plain';
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// Clipboard access is unavailable.
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What one copy writes: rich HTML (or none) and the plain text, plus why. */
|
|
296
|
+
interface CopyPlan {
|
|
297
|
+
html: string | null;
|
|
298
|
+
text: string;
|
|
299
|
+
reason?: 'too-large' | 'no-markup';
|
|
300
|
+
truncatedTo?: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function planCopy(source: RichCopySource, fallbackText: string): CopyPlan {
|
|
304
|
+
if (!source) return { html: null, text: fallbackText, reason: 'no-markup' };
|
|
305
|
+
const text = source.text || fallbackText;
|
|
306
|
+
const truncatedTo = source.truncated ? text.length : undefined;
|
|
307
|
+
if ('tooLarge' in source) return { html: null, text, reason: 'too-large', truncatedTo };
|
|
308
|
+
if (!source.html) return { html: null, text, reason: 'no-markup', truncatedTo };
|
|
309
|
+
if (source.html.length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) {
|
|
310
|
+
return { html: null, text, reason: 'too-large', truncatedTo };
|
|
311
|
+
}
|
|
312
|
+
return { html: source.html, text, truncatedTo };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Plain text as clipboard HTML: escaped, in a <pre>, so the html part of a
|
|
316
|
+
* promised clipboard item can always resolve. */
|
|
317
|
+
export function plainTextClipboardHtml(text: string): string {
|
|
318
|
+
const escaped = text
|
|
319
|
+
.replace(/&/g, '&')
|
|
320
|
+
.replace(/</g, '<')
|
|
321
|
+
.replace(/>/g, '>');
|
|
322
|
+
return `<pre>${escaped}</pre>`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function copyPlanned(plan: CopyPlan): Promise<RichCopyOutcome | false> {
|
|
326
|
+
if (plan.html === null) {
|
|
327
|
+
return await copyTextToClipboard(plan.text)
|
|
328
|
+
? { copied: 'plain', reason: plan.reason, truncatedTo: plan.truncatedTo }
|
|
329
|
+
: false;
|
|
330
|
+
}
|
|
331
|
+
const result = await copyRichTextToClipboard(plan.html, plan.text);
|
|
332
|
+
if (result === 'rich') return { copied: 'rich', truncatedTo: plan.truncatedTo };
|
|
333
|
+
if (result === 'plain') return { copied: 'plain', reason: 'unavailable', truncatedTo: plan.truncatedTo };
|
|
334
|
+
if (result === 'too-large') return { copied: 'plain', reason: 'too-large', truncatedTo: plan.truncatedTo };
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The toolbar button's copy: builds the rich HTML only now, through `build`
|
|
340
|
+
* (synchronous for the markdown Viewer, a bridge round trip on HTML surfaces).
|
|
341
|
+
* An asynchronous source is handed to the clipboard as ONE item with promised
|
|
342
|
+
* blobs inside the click, so browsers that need the write to start in the
|
|
343
|
+
* gesture (Safari, WKWebView) keep it. Neither promise ever rejects: with no
|
|
344
|
+
* rich markup (too large, none came back, a timeout) the html part is the
|
|
345
|
+
* plain text, escaped, and the outcome says plain.
|
|
346
|
+
*/
|
|
347
|
+
export async function copyRichSelection(
|
|
348
|
+
build: () => RichCopySource | Promise<RichCopySource>,
|
|
349
|
+
fallbackText: string,
|
|
350
|
+
): Promise<RichCopyOutcome | false> {
|
|
351
|
+
let source: RichCopySource | Promise<RichCopySource>;
|
|
352
|
+
try {
|
|
353
|
+
source = build();
|
|
354
|
+
} catch {
|
|
355
|
+
source = null;
|
|
356
|
+
}
|
|
357
|
+
if (!(source instanceof Promise)) return copyPlanned(planCopy(source, fallbackText));
|
|
358
|
+
|
|
359
|
+
const plan = source.then(
|
|
360
|
+
(value) => planCopy(value, fallbackText),
|
|
361
|
+
() => planCopy(null, fallbackText),
|
|
362
|
+
);
|
|
363
|
+
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
|
364
|
+
try {
|
|
365
|
+
const item = new ClipboardItem({
|
|
366
|
+
'text/html': plan.then((p) =>
|
|
367
|
+
new Blob([p.html ?? plainTextClipboardHtml(p.text)], { type: 'text/html' })),
|
|
368
|
+
'text/plain': plan.then((p) => new Blob([p.text], { type: 'text/plain' })),
|
|
369
|
+
});
|
|
370
|
+
await navigator.clipboard.write([item]);
|
|
371
|
+
const done = await plan;
|
|
372
|
+
return done.html !== null
|
|
373
|
+
? { copied: 'rich', truncatedTo: done.truncatedTo }
|
|
374
|
+
: { copied: 'plain', reason: done.reason, truncatedTo: done.truncatedTo };
|
|
375
|
+
} catch {
|
|
376
|
+
// The write itself was refused: the plain paths below (best effort).
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return copyPlanned(await plan);
|
|
380
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
interface ListFrame {
|
|
2
|
+
level: number;
|
|
3
|
+
kind: 'ol' | 'ul';
|
|
4
|
+
list: HTMLOListElement | HTMLUListElement;
|
|
5
|
+
lastItem: HTMLLIElement | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Restore semantic lists in a cloned Markdown selection before sanitization. */
|
|
9
|
+
export function restoreClipboardLists(fragment: ParentNode): void {
|
|
10
|
+
const output = document.createDocumentFragment();
|
|
11
|
+
const stack: ListFrame[] = [];
|
|
12
|
+
|
|
13
|
+
for (const node of Array.from(fragment.childNodes)) {
|
|
14
|
+
const levelText = node instanceof Element ? node.getAttribute('data-rich-list-level') : null;
|
|
15
|
+
const kind = node instanceof Element ? node.getAttribute('data-rich-list-kind') : null;
|
|
16
|
+
const level = levelText === null ? -1 : Number(levelText);
|
|
17
|
+
if (!(node instanceof Element) || !Number.isSafeInteger(level) || level < 0 || (kind !== 'ol' && kind !== 'ul')) {
|
|
18
|
+
stack.length = 0;
|
|
19
|
+
if (node instanceof Element) restoreClipboardLists(node);
|
|
20
|
+
output.appendChild(node);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
while (stack.length && stack[stack.length - 1].level > level) stack.pop();
|
|
25
|
+
let current = stack[stack.length - 1];
|
|
26
|
+
if (current?.level === level && current.kind !== kind) {
|
|
27
|
+
stack.pop();
|
|
28
|
+
current = stack[stack.length - 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const indexText = node.getAttribute('data-rich-list-index');
|
|
32
|
+
const index = indexText === null ? null : Number(indexText);
|
|
33
|
+
if (!current || current.level < level) {
|
|
34
|
+
const list = document.createElement(kind);
|
|
35
|
+
if (kind === 'ol' && index !== null && Number.isSafeInteger(index)) {
|
|
36
|
+
list.setAttribute('start', String(index));
|
|
37
|
+
}
|
|
38
|
+
(current?.lastItem ?? output).appendChild(list);
|
|
39
|
+
current = { level, kind, list, lastItem: null };
|
|
40
|
+
stack.push(current);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const item = document.createElement('li');
|
|
44
|
+
if (kind === 'ol' && index !== null && Number.isSafeInteger(index)) {
|
|
45
|
+
item.setAttribute('value', String(index));
|
|
46
|
+
}
|
|
47
|
+
const checked = node.getAttribute('data-rich-list-checked');
|
|
48
|
+
if (checked === 'true' || checked === 'false') {
|
|
49
|
+
item.appendChild(document.createTextNode(checked === 'true' ? '☑ ' : '☐ '));
|
|
50
|
+
}
|
|
51
|
+
while (node.firstChild) item.appendChild(node.firstChild);
|
|
52
|
+
current.list.appendChild(item);
|
|
53
|
+
current.lastItem = item;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fragment.replaceChildren(output);
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the selection toolbar's Copy button copies rich text (text/html plus
|
|
3
|
+
* text/plain in one write) (#1972, #2191).
|
|
4
|
+
*
|
|
5
|
+
* Default: false, so a host that does not opt in (Workspaces) keeps the
|
|
6
|
+
* toolbar and its plain-text Copy exactly as before. Plannotator's own apps (plan review and annotate,
|
|
7
|
+
* code review, the Inbox window) pass `configurePlannotatorUI({ richCopy: true })`.
|
|
8
|
+
* Read when the Viewer and HtmlViewer render their toolbar.
|
|
9
|
+
*/
|
|
10
|
+
let richCopy = false;
|
|
11
|
+
|
|
12
|
+
export function setRichCopyEnabled(on: boolean): void {
|
|
13
|
+
richCopy = on;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resetRichCopyEnabled(): void {
|
|
17
|
+
richCopy = false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getRichCopyEnabled(): boolean {
|
|
21
|
+
return richCopy;
|
|
22
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember where a reader was in a rendered document, and put them back there.
|
|
3
|
+
*
|
|
4
|
+
* Raw pixels are a poor bookmark: images that finish loading, highlights that
|
|
5
|
+
* are re-applied, or a sidebar that changes the reading column's width all move
|
|
6
|
+
* the text under a fixed scrollTop. So the anchor is the first block
|
|
7
|
+
* (`data-block-id`) still visible at the top of the scroll viewport, plus how
|
|
8
|
+
* far its top sat from the viewport's top. Restoring scrolls that block back to
|
|
9
|
+
* the same offset; the raw scrollTop is only the fallback when the block is
|
|
10
|
+
* gone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getScrollViewportRect,
|
|
15
|
+
getScrollViewportTop,
|
|
16
|
+
offsetScrollViewport,
|
|
17
|
+
scrollViewportTo,
|
|
18
|
+
} from '../hooks/useScrollViewport';
|
|
19
|
+
|
|
20
|
+
export interface ScrollAnchor {
|
|
21
|
+
/** The first block visible at the top of the viewport, null when none is. */
|
|
22
|
+
blockId: string | null;
|
|
23
|
+
/** That block's top minus the viewport's top, in CSS pixels (≤ 0 when the
|
|
24
|
+
* block starts above the viewport). */
|
|
25
|
+
offset: number;
|
|
26
|
+
/** The viewport's scrollTop when captured — the fallback. */
|
|
27
|
+
scrollTop: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blocksIn(viewport: HTMLElement): HTMLElement[] {
|
|
31
|
+
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-block-id]'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Capture where the reader is in `viewport`. */
|
|
35
|
+
export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor {
|
|
36
|
+
const scrollTop = getScrollViewportTop(viewport);
|
|
37
|
+
const viewportTop = getScrollViewportRect(viewport).top;
|
|
38
|
+
for (const block of blocksIn(viewport)) {
|
|
39
|
+
const rect = block.getBoundingClientRect();
|
|
40
|
+
if (rect.height <= 0 && rect.width <= 0) continue;
|
|
41
|
+
if (rect.bottom > viewportTop + 1) {
|
|
42
|
+
return {
|
|
43
|
+
blockId: block.getAttribute('data-block-id'),
|
|
44
|
+
offset: rect.top - viewportTop,
|
|
45
|
+
scrollTop,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { blockId: null, offset: 0, scrollTop };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findBlock(viewport: HTMLElement, blockId: string): HTMLElement | null {
|
|
53
|
+
for (const block of blocksIn(viewport)) {
|
|
54
|
+
if (block.getAttribute('data-block-id') === blockId) return block;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the anchor's block is in the viewport's DOM yet. A document that is
|
|
61
|
+
* still mounting has none of its blocks, so a caller can wait for this.
|
|
62
|
+
*/
|
|
63
|
+
export function scrollAnchorReady(viewport: HTMLElement, anchor: ScrollAnchor): boolean {
|
|
64
|
+
if (anchor.blockId === null) return blocksIn(viewport).length > 0 || anchor.scrollTop === 0;
|
|
65
|
+
return findBlock(viewport, anchor.blockId) !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Put the reader back at `anchor`. Returns the viewport's scrollTop afterwards.
|
|
70
|
+
* When the anchor's block is gone, falls back to the captured scrollTop.
|
|
71
|
+
*/
|
|
72
|
+
export function restoreScrollAnchor(viewport: HTMLElement, anchor: ScrollAnchor): number {
|
|
73
|
+
const block = anchor.blockId === null ? null : findBlock(viewport, anchor.blockId);
|
|
74
|
+
if (block) {
|
|
75
|
+
const viewportTop = getScrollViewportRect(viewport).top;
|
|
76
|
+
const delta = block.getBoundingClientRect().top - viewportTop - anchor.offset;
|
|
77
|
+
if (Math.abs(delta) >= 1) offsetScrollViewport(viewport, delta);
|
|
78
|
+
} else if (Math.abs(getScrollViewportTop(viewport) - anchor.scrollTop) >= 1) {
|
|
79
|
+
scrollViewportTo(viewport, { top: anchor.scrollTop, behavior: 'auto' });
|
|
80
|
+
}
|
|
81
|
+
return getScrollViewportTop(viewport);
|
|
82
|
+
}
|