@plannotator/ui 0.53.1 → 0.55.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.
Files changed (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. 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 target = anchor ? `${subject} [${safeInline(diagramAnchorLocationLine(anchor), 300)}]` : subject;
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
+ }
@@ -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, '&amp;')
320
+ .replace(/</g, '&lt;')
321
+ .replace(/>/g, '&gt;');
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
+ }