@plannotator/ui 0.53.0 → 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 +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- 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 +140 -5
- package/components/CommentPopover.tsx +46 -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/HtmlSurfaceControls.tsx +2 -1
- 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/MentionPicker.tsx +51 -0
- 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/SkillReferenceMenu.tsx +39 -12
- 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 +316 -47
- 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/blocks/QuestionContext.tsx +3 -0
- 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 +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- 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/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- 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 +14 -6
- 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/mentions.ts +34 -5
- 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/parser.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachme
|
|
|
2
2
|
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
3
|
import { resolveReplyParents } from '@plannotator/core/annotation-threads';
|
|
4
4
|
import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
|
|
5
|
+
import { imageRegionLocationLines, parseImageRegion, type ImageRegion } from '@plannotator/core/image-region';
|
|
5
6
|
import {
|
|
6
7
|
formatQuestionAnswerLines,
|
|
7
8
|
formatQuestionAnswerText,
|
|
@@ -779,6 +780,8 @@ const isElementPlaceholderQuote = (text: unknown): boolean =>
|
|
|
779
780
|
* element instead (`Feedback on the <nav> element — "Primary"`); every other
|
|
780
781
|
* annotation keeps the quote line exactly as before. */
|
|
781
782
|
const commentHeadingLine = (ann: any): string => {
|
|
783
|
+
const region = imageRegionOf(ann);
|
|
784
|
+
if (region) return `Feedback on a region of the image ${safeInline(region.src, 600)}`;
|
|
782
785
|
const element = elementSubject(ann);
|
|
783
786
|
return element ? `Feedback on ${element}` : `Feedback on: "${ann.originalText}"`;
|
|
784
787
|
};
|
|
@@ -1001,6 +1004,8 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
|
|
|
1001
1004
|
default:
|
|
1002
1005
|
if (ann?.isQuickLabel) {
|
|
1003
1006
|
output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
|
|
1007
|
+
const region = imageRegionOf(ann);
|
|
1008
|
+
if (region) output += imageRegionExportLines(region);
|
|
1004
1009
|
if (ann.quickLabelTip) output += `> ${ann.quickLabelTip}\n`;
|
|
1005
1010
|
} else {
|
|
1006
1011
|
output += `${commentHeadingLine(ann)}\n${diagramLocationExportLine(ann)}> ${ann?.text ?? ''}\n`;
|
|
@@ -1021,12 +1026,28 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
|
|
|
1021
1026
|
return output;
|
|
1022
1027
|
};
|
|
1023
1028
|
|
|
1029
|
+
/** The validated region of a comment on part of an image (#2100), or null
|
|
1030
|
+
* for every other annotation and for a malformed one (an older or foreign
|
|
1031
|
+
* writer), which then exports as the ordinary comment it also is. */
|
|
1032
|
+
const imageRegionOf = (ann: any): ImageRegion | null =>
|
|
1033
|
+
ann?.imageRegion == null ? null : parseImageRegion(ann.imageRegion);
|
|
1034
|
+
|
|
1035
|
+
/** The two location lines under a region comment (the approved prototype):
|
|
1036
|
+
* `Image: figures/status-colors.png (line 63)` then
|
|
1037
|
+
* `Region: x=1820 y=380 w=420 h=160 (of 3664×1453)` — the image as written,
|
|
1038
|
+
* its line, and the box in the image's own pixels. The src is collapsed to
|
|
1039
|
+
* one line and defused like every page-controlled string. */
|
|
1040
|
+
const imageRegionExportLines = (region: ImageRegion): string =>
|
|
1041
|
+
`${imageRegionLocationLines({ ...region, src: safeInline(region.src, 600) })}\n`;
|
|
1042
|
+
|
|
1024
1043
|
/** The location line under a comment made on a rendered diagram part:
|
|
1025
1044
|
* `Diagram node Approve? (D), line 4` — the part's own id (what the agent
|
|
1026
1045
|
* greps the fence for) and the DOCUMENT line that declares it. Emits
|
|
1027
1046
|
* nothing for every other annotation, keeping their output byte-identical;
|
|
1028
1047
|
* a malformed anchor (an older or foreign writer) is skipped, never thrown. */
|
|
1029
1048
|
const diagramLocationExportLine = (ann: any): string => {
|
|
1049
|
+
const region = imageRegionOf(ann);
|
|
1050
|
+
if (region) return imageRegionExportLines(region);
|
|
1030
1051
|
const anchor = ann?.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
|
|
1031
1052
|
if (anchor !== null) return `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
|
|
1032
1053
|
return hostLocationExportLine(ann);
|
|
@@ -1043,6 +1064,9 @@ const hostLocationExportLine = (ann: any): string => {
|
|
|
1043
1064
|
|
|
1044
1065
|
const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
1045
1066
|
if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
|
|
1067
|
+
// A region names the line its image is written on, not its block's span.
|
|
1068
|
+
const region = imageRegionOf(ann);
|
|
1069
|
+
if (region?.sourceLine != null) return `line ${region.sourceLine}`;
|
|
1046
1070
|
if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
|
|
1047
1071
|
const block = blocks.find(b => b.id === ann.blockId);
|
|
1048
1072
|
if (!block || typeof block.startLine !== 'number') return null;
|
|
@@ -1615,7 +1639,10 @@ export const formatDraftAnnotationsForAsk = (input: DraftAnnotationsForAsk): str
|
|
|
1615
1639
|
} else {
|
|
1616
1640
|
const subject = elementSubject(ann) ?? quote(ann.originalText);
|
|
1617
1641
|
const anchor = ann.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
|
|
1618
|
-
const
|
|
1642
|
+
const region = imageRegionOf(ann);
|
|
1643
|
+
const target = region
|
|
1644
|
+
? `a region of the image ${quote(region.src)} [${safeInline(imageRegionLocationLines(region).replace('\n', '; '), 300)}]`
|
|
1645
|
+
: anchor ? `${subject} [${safeInline(diagramAnchorLocationLine(anchor), 300)}]` : subject;
|
|
1619
1646
|
body = ann.isQuickLabel
|
|
1620
1647
|
? `label ${quote(ann.text)} on ${target}`
|
|
1621
1648
|
: `comment on ${target} — ${text(ann.text)}`;
|
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
|
+
}
|