@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,122 @@
1
+ import React, { useRef, useEffect } from "react";
2
+ import { isCodeFilePath } from "@plannotator/core/code-file";
3
+ import { Block } from "../../types";
4
+ import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
5
+ import { getImageSrc } from "../ImageThumbnail";
6
+
7
+ interface HtmlBlockProps {
8
+ block: Block;
9
+ imageBaseDir?: string;
10
+ onOpenLinkedDoc?: (path: string) => void;
11
+ onOpenCodeFile?: (path: string) => void;
12
+ onNavigateAnchor?: (hash: string) => void;
13
+ }
14
+
15
+ // Walks the sanitized DOM and rewrites relative <img src> / <a href> so they
16
+ // behave the same as their markdown counterparts:
17
+ // - Relative image paths route through /api/image?path=... so they load from
18
+ // the plan's directory, not the plannotator server root.
19
+ // - Relative .md / .mdx / .txt / .html links open in the linked-doc overlay when
20
+ // onOpenLinkedDoc is provided (same as [[wiki-links]] and [label](./x.md)).
21
+ // Absolute http(s) URLs and mailto: are left untouched.
22
+ function rewriteRelativeRefs(
23
+ root: HTMLElement,
24
+ imageBaseDir?: string,
25
+ onOpenLinkedDoc?: (path: string) => void,
26
+ onOpenCodeFile?: (path: string) => void,
27
+ onNavigateAnchor?: (hash: string) => void,
28
+ ): (() => void) {
29
+ const cleanups: (() => void)[] = [];
30
+
31
+ root.querySelectorAll('img').forEach((img) => {
32
+ const src = img.getAttribute('src');
33
+ if (!src) return;
34
+ if (/^(https?:|data:|blob:)/i.test(src)) return;
35
+ img.setAttribute('src', getImageSrc(src, imageBaseDir));
36
+ });
37
+
38
+ root.querySelectorAll('a').forEach((a) => {
39
+ const href = a.getAttribute('href');
40
+ if (!href) return;
41
+ // External http(s) links: open in a new tab and close the tab-nabbing
42
+ // vector (opener reference back to the plannotator tab). Matches the
43
+ // markdown renderer's behavior for [label](https://...).
44
+ if (/^(https?:|\/\/)/i.test(href)) {
45
+ a.setAttribute('target', '_blank');
46
+ a.setAttribute('rel', 'noopener noreferrer');
47
+ return;
48
+ }
49
+ if (/^(mailto:|tel:)/i.test(href)) return;
50
+ // In-page anchor: native browser jump doesn't target the scroll viewport,
51
+ // so route through onNavigateAnchor to match InlineMarkdown's behavior.
52
+ if (href.startsWith('#')) {
53
+ if (!onNavigateAnchor) return;
54
+ const handler = (e: Event) => {
55
+ e.preventDefault();
56
+ onNavigateAnchor(href);
57
+ };
58
+ a.addEventListener('click', handler);
59
+ cleanups.push(() => a.removeEventListener('click', handler));
60
+ return;
61
+ }
62
+ if (onOpenCodeFile && isCodeFilePath(href)) {
63
+ const handler = (e: Event) => {
64
+ e.preventDefault();
65
+ onOpenCodeFile(href.replace(/#.*$/, ''));
66
+ };
67
+ a.addEventListener('click', handler);
68
+ cleanups.push(() => a.removeEventListener('click', handler));
69
+ return;
70
+ }
71
+ if (onOpenLinkedDoc && /\.(mdx?|txt|html?)(#.*)?$/i.test(href)) {
72
+ const handler = (e: Event) => {
73
+ e.preventDefault();
74
+ onOpenLinkedDoc(href.replace(/#.*$/, ''));
75
+ };
76
+ a.addEventListener('click', handler);
77
+ cleanups.push(() => a.removeEventListener('click', handler));
78
+ }
79
+ });
80
+
81
+ return () => cleanups.forEach((fn) => fn());
82
+ }
83
+
84
+ // The inner HTML is set imperatively (not via dangerouslySetInnerHTML) so that
85
+ // React's reconciliation never replaces the rendered subtree on re-render.
86
+ // That matters because <details open> is DOM-owned state — a stray innerHTML
87
+ // re-set on every parent re-render would collapse any open <details> the
88
+ // user just opened. Paired with React.memo below so the component itself
89
+ // stops re-rendering unless the block content actually changes.
90
+ const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor }) => {
91
+ const ref = useRef<HTMLDivElement>(null);
92
+ const sanitized = React.useMemo(
93
+ () => sanitizeBlockHtml(block.content),
94
+ [block.content],
95
+ );
96
+ useEffect(() => {
97
+ if (!ref.current) return;
98
+ if (ref.current.innerHTML !== sanitized) {
99
+ ref.current.innerHTML = sanitized;
100
+ }
101
+ const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor);
102
+ return cleanup;
103
+ }, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor]);
104
+ return (
105
+ <div
106
+ ref={ref}
107
+ data-block-id={block.id}
108
+ data-block-type="html"
109
+ className="html-block my-4 text-[15px] leading-relaxed text-foreground/90"
110
+ />
111
+ );
112
+ };
113
+ export const HtmlBlock = React.memo(
114
+ HtmlBlockImpl,
115
+ (prev, next) =>
116
+ prev.block.id === next.block.id &&
117
+ prev.block.content === next.block.content &&
118
+ prev.imageBaseDir === next.imageBaseDir &&
119
+ prev.onOpenLinkedDoc === next.onOpenLinkedDoc &&
120
+ prev.onOpenCodeFile === next.onOpenCodeFile &&
121
+ prev.onNavigateAnchor === next.onNavigateAnchor,
122
+ );
@@ -0,0 +1,36 @@
1
+ import React, { useMemo } from 'react';
2
+ import katex from 'katex';
3
+ import type { Block } from '../../types';
4
+
5
+ type MathBlockProps = {
6
+ block: Block;
7
+ };
8
+
9
+ export const normalizeMathTex = (tex: string): string => tex.trim();
10
+
11
+ export const renderMathToHtml = (tex: string, displayMode: boolean): string => (
12
+ katex.renderToString(tex, {
13
+ displayMode,
14
+ throwOnError: false,
15
+ strict: 'warn',
16
+ trust: false,
17
+ output: 'html',
18
+ })
19
+ );
20
+
21
+ export const MathBlock: React.FC<MathBlockProps> = ({ block }) => {
22
+ const tex = normalizeMathTex(block.content);
23
+ const html = useMemo(() => renderMathToHtml(tex, true), [tex]);
24
+
25
+ return (
26
+ <div
27
+ className="math-block math-annotatable my-5 overflow-x-auto py-2 text-foreground"
28
+ data-block-id={block.id}
29
+ data-block-type="math"
30
+ data-math-tex={tex}
31
+ data-math-display="true"
32
+ aria-label={tex}
33
+ dangerouslySetInnerHTML={{ __html: html }}
34
+ />
35
+ );
36
+ };
@@ -0,0 +1,146 @@
1
+ import React, { useRef } from 'react';
2
+ import { Block } from '../../types';
3
+ import { InlineMarkdown } from '../InlineMarkdown';
4
+
5
+ interface TableBlockProps {
6
+ block: Block;
7
+ onHover?: (element: HTMLElement) => void;
8
+ onLeave?: () => void;
9
+ onOpenLinkedDoc?: (path: string) => void;
10
+ onOpenCodeFile?: (path: string) => void;
11
+ onNavigateAnchor?: (hash: string) => void;
12
+ imageBaseDir?: string;
13
+ onImageClick?: (src: string, alt: string) => void;
14
+ githubRepo?: string;
15
+ }
16
+
17
+ // Parse pipe-delimited markdown table content into headers + rows.
18
+ // Exported so TableToolbar can reuse it to build a CSV copy without
19
+ // needing a second parser.
20
+ export const parseTableContent = (content: string): { headers: string[]; rows: string[][] } => {
21
+ const lines = content.split('\n').filter((line) => line.trim());
22
+ if (lines.length === 0) return { headers: [], rows: [] };
23
+
24
+ const parseRow = (line: string): string[] =>
25
+ line
26
+ .replace(/^\|/, '')
27
+ .replace(/\|$/, '')
28
+ .split(/(?<!\\)\|/)
29
+ .map((cell) => cell.trim().replace(/\\\|/g, '|'));
30
+
31
+ const headers = parseRow(lines[0]);
32
+ const rows: string[][] = [];
33
+ for (let i = 1; i < lines.length; i++) {
34
+ const line = lines[i].trim();
35
+ if (/^[\|\-:\s]+$/.test(line)) continue; // separator row
36
+ rows.push(parseRow(line));
37
+ }
38
+ return { headers, rows };
39
+ };
40
+
41
+ // RFC 4180 CSV escaping: quote values that contain a comma, double-quote,
42
+ // CR, or LF; escape inner double-quotes by doubling them.
43
+ const csvEscape = (value: string): string => {
44
+ if (/[",\r\n]/.test(value)) {
45
+ return `"${value.replace(/"/g, '""')}"`;
46
+ }
47
+ return value;
48
+ };
49
+
50
+ // Build RFC 4180 CSV from pre-parsed headers + rows. The popout uses this
51
+ // directly with TanStack's visible (filter + sort applied) rows.
52
+ export const buildCsvFromRows = (headers: string[], rows: string[][]): string => {
53
+ const lines = [headers, ...rows].map((row) => row.map(csvEscape).join(','));
54
+ return lines.join('\n');
55
+ };
56
+
57
+ // Rebuild a pipe-delimited markdown table from pre-parsed headers + rows.
58
+ // Inverse of parseTableContent. Whitespace normalized (one space per cell
59
+ // padding). The popout uses this for copy-as-markdown when filter or sort
60
+ // might change what the user sees.
61
+ // parseTableContent unescapes `\|` → `|`, so cells may hold literal pipes.
62
+ // Re-escape on the way out — otherwise the serialized table sprouts extra
63
+ // columns wherever a cell had a pipe.
64
+ const mdCellEscape = (value: string): string => value.replace(/\|/g, '\\|');
65
+
66
+ export const buildMarkdownTable = (headers: string[], rows: string[][]): string => {
67
+ const headerLine = `| ${headers.map(mdCellEscape).join(' | ')} |`;
68
+ const separator = `| ${headers.map(() => '---').join(' | ')} |`;
69
+ const bodyLines = rows.map((row) => `| ${row.map(mdCellEscape).join(' | ')} |`);
70
+ return [headerLine, separator, ...bodyLines].join('\n');
71
+ };
72
+
73
+ // Convert the markdown table's pipe-delimited source into RFC 4180 CSV.
74
+ // The hover toolbar uses this on the raw block content (no filter state).
75
+ export const buildCsv = (markdown: string): string => {
76
+ const { headers, rows } = parseTableContent(markdown);
77
+ return buildCsvFromRows(headers, rows);
78
+ };
79
+
80
+ export const TableBlock: React.FC<TableBlockProps> = ({
81
+ block,
82
+ onHover,
83
+ onLeave,
84
+ onOpenLinkedDoc,
85
+ onOpenCodeFile,
86
+ onNavigateAnchor,
87
+ imageBaseDir,
88
+ onImageClick,
89
+ githubRepo,
90
+ }) => {
91
+ const containerRef = useRef<HTMLDivElement>(null);
92
+ const { headers, rows } = parseTableContent(block.content);
93
+
94
+ const handleMouseEnter = () => {
95
+ if (containerRef.current && onHover) onHover(containerRef.current);
96
+ };
97
+
98
+ return (
99
+ <div
100
+ ref={containerRef}
101
+ className="my-4 overflow-x-auto"
102
+ data-block-id={block.id}
103
+ onMouseEnter={handleMouseEnter}
104
+ onMouseLeave={onLeave}
105
+ >
106
+ <table className="min-w-full border-collapse text-sm">
107
+ <thead>
108
+ <tr className="border-b border-border">
109
+ {headers.map((header, i) => (
110
+ <th key={i} className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30">
111
+ <InlineMarkdown
112
+ imageBaseDir={imageBaseDir}
113
+ onImageClick={onImageClick}
114
+ text={header}
115
+ onOpenLinkedDoc={onOpenLinkedDoc}
116
+ onOpenCodeFile={onOpenCodeFile}
117
+ onNavigateAnchor={onNavigateAnchor}
118
+ githubRepo={githubRepo}
119
+ />
120
+ </th>
121
+ ))}
122
+ </tr>
123
+ </thead>
124
+ <tbody>
125
+ {rows.map((row, rowIdx) => (
126
+ <tr key={rowIdx} className="border-b border-border/50 hover:bg-muted/20">
127
+ {row.map((cell, cellIdx) => (
128
+ <td key={cellIdx} className="px-3 py-2 text-foreground/80">
129
+ <InlineMarkdown
130
+ imageBaseDir={imageBaseDir}
131
+ onImageClick={onImageClick}
132
+ text={cell}
133
+ onOpenLinkedDoc={onOpenLinkedDoc}
134
+ onOpenCodeFile={onOpenCodeFile}
135
+ onNavigateAnchor={onNavigateAnchor}
136
+ githubRepo={githubRepo}
137
+ />
138
+ </td>
139
+ ))}
140
+ </tr>
141
+ ))}
142
+ </tbody>
143
+ </table>
144
+ </div>
145
+ );
146
+ };
@@ -0,0 +1,279 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ createColumnHelper,
4
+ flexRender,
5
+ getCoreRowModel,
6
+ getFilteredRowModel,
7
+ getSortedRowModel,
8
+ useReactTable,
9
+ type ColumnDef,
10
+ type SortingState,
11
+ } from '@tanstack/react-table';
12
+ import { Block } from '../../types';
13
+ import { InlineMarkdown } from '../InlineMarkdown';
14
+ import { PopoutDialog } from '../PopoutDialog';
15
+ import { parseTableContent, buildCsvFromRows, buildMarkdownTable } from './TableBlock';
16
+
17
+ interface TablePopoutProps {
18
+ block: Block;
19
+ open: boolean;
20
+ onClose: () => void;
21
+ /** Portal target — pass Viewer's annotation containerRef so annotation
22
+ * hooks can walk into the popout's text nodes. Null falls back to body. */
23
+ container?: HTMLElement | null;
24
+ onOpenLinkedDoc?: (path: string) => void;
25
+ onOpenCodeFile?: (path: string) => void;
26
+ onNavigateAnchor?: (hash: string) => void;
27
+ imageBaseDir?: string;
28
+ onImageClick?: (src: string, alt: string) => void;
29
+ githubRepo?: string;
30
+ }
31
+
32
+ type Row = Record<string, string>;
33
+
34
+ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
35
+ block,
36
+ open,
37
+ onClose,
38
+ container,
39
+ onOpenLinkedDoc,
40
+ onOpenCodeFile,
41
+ onNavigateAnchor,
42
+ imageBaseDir,
43
+ onImageClick,
44
+ githubRepo,
45
+ }) => {
46
+ const { headers, rows } = useMemo(() => parseTableContent(block.content), [block.content]);
47
+
48
+ const columnIds = useMemo(() => {
49
+ const seen = new Map<string, number>();
50
+ return headers.map((h, i) => {
51
+ const base = h.trim() || `col-${i}`;
52
+ const n = (seen.get(base) ?? 0) + 1;
53
+ seen.set(base, n);
54
+ return n === 1 ? base : `${base}-${n}`;
55
+ });
56
+ }, [headers]);
57
+
58
+ const data = useMemo<Row[]>(
59
+ () =>
60
+ rows.map((row) => {
61
+ const obj: Row = {};
62
+ columnIds.forEach((id, i) => {
63
+ obj[id] = row[i] ?? '';
64
+ });
65
+ return obj;
66
+ }),
67
+ [rows, columnIds],
68
+ );
69
+
70
+ const columns = useMemo<ColumnDef<Row, string>[]>(() => {
71
+ const helper = createColumnHelper<Row>();
72
+ return columnIds.map((id, i) =>
73
+ helper.accessor((row) => row[id], {
74
+ id,
75
+ header: headers[i],
76
+ cell: (info) => (
77
+ <InlineMarkdown
78
+ imageBaseDir={imageBaseDir}
79
+ onImageClick={onImageClick}
80
+ text={info.getValue()}
81
+ onOpenLinkedDoc={onOpenLinkedDoc}
82
+ onOpenCodeFile={onOpenCodeFile}
83
+ onNavigateAnchor={onNavigateAnchor}
84
+ githubRepo={githubRepo}
85
+ />
86
+ ),
87
+ }),
88
+ );
89
+ }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo]);
90
+
91
+ const [sorting, setSorting] = useState<SortingState>([]);
92
+ const [globalFilter, setGlobalFilter] = useState('');
93
+ const [copiedMd, setCopiedMd] = useState(false);
94
+ const [copiedCsv, setCopiedCsv] = useState(false);
95
+
96
+ const getVisibleRowsData = (): string[][] =>
97
+ table.getRowModel().rows.map((row) =>
98
+ columnIds.map((id) => row.getValue<string>(id) ?? ''),
99
+ );
100
+
101
+ const handleCopyMarkdown = async () => {
102
+ try {
103
+ await navigator.clipboard.writeText(buildMarkdownTable(headers, getVisibleRowsData()));
104
+ setCopiedMd(true);
105
+ setTimeout(() => setCopiedMd(false), 1500);
106
+ } catch (err) {
107
+ console.error('Failed to copy:', err);
108
+ }
109
+ };
110
+
111
+ const handleCopyCsv = async () => {
112
+ try {
113
+ await navigator.clipboard.writeText(buildCsvFromRows(headers, getVisibleRowsData()));
114
+ setCopiedCsv(true);
115
+ setTimeout(() => setCopiedCsv(false), 1500);
116
+ } catch (err) {
117
+ console.error('Failed to copy:', err);
118
+ }
119
+ };
120
+
121
+ const table = useReactTable({
122
+ data,
123
+ columns,
124
+ state: { sorting, globalFilter },
125
+ onSortingChange: setSorting,
126
+ onGlobalFilterChange: setGlobalFilter,
127
+ getCoreRowModel: getCoreRowModel(),
128
+ getSortedRowModel: getSortedRowModel(),
129
+ getFilteredRowModel: getFilteredRowModel(),
130
+ globalFilterFn: 'includesString',
131
+ });
132
+
133
+ const visibleRows = table.getRowModel().rows;
134
+ const totalRows = data.length;
135
+
136
+ return (
137
+ <PopoutDialog
138
+ open={open}
139
+ onClose={onClose}
140
+ title="Table"
141
+ container={container}
142
+ dataAttributes={{ 'data-block-id': block.id }}
143
+ >
144
+ <div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
145
+ <div className="relative max-w-sm flex-1">
146
+ <svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
147
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M10 18a8 8 0 100-16 8 8 0 000 16z" />
148
+ </svg>
149
+ <input
150
+ type="text"
151
+ value={globalFilter}
152
+ onChange={(e) => setGlobalFilter(e.target.value)}
153
+ placeholder="Filter rows…"
154
+ className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50"
155
+ />
156
+ </div>
157
+ <span className="text-xs text-muted-foreground tabular-nums">
158
+ {visibleRows.length === totalRows
159
+ ? `${totalRows} row${totalRows === 1 ? '' : 's'}`
160
+ : `${visibleRows.length} of ${totalRows}`}
161
+ </span>
162
+ <div className="ml-auto flex items-center gap-1">
163
+ <button
164
+ onClick={handleCopyMarkdown}
165
+ title={
166
+ copiedMd
167
+ ? 'Copied!'
168
+ : visibleRows.length === totalRows
169
+ ? 'Copy as markdown'
170
+ : `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as markdown`
171
+ }
172
+ className={`p-1.5 rounded-md transition-colors ${
173
+ copiedMd ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
174
+ }`}
175
+ >
176
+ {copiedMd ? (
177
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
178
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
179
+ </svg>
180
+ ) : (
181
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
182
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
183
+ </svg>
184
+ )}
185
+ </button>
186
+ <button
187
+ onClick={handleCopyCsv}
188
+ title={
189
+ copiedCsv
190
+ ? 'Copied as CSV!'
191
+ : visibleRows.length === totalRows
192
+ ? 'Copy as CSV'
193
+ : `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as CSV`
194
+ }
195
+ className={`px-2 py-1 rounded-md text-[10px] font-bold tracking-tight uppercase leading-none transition-colors ${
196
+ copiedCsv ? 'text-success' : 'text-primary hover:bg-primary/10'
197
+ }`}
198
+ >
199
+ {copiedCsv ? '✓' : 'CSV'}
200
+ </button>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="overflow-auto flex-1 px-5 pb-5">
205
+ <table className="min-w-full border-collapse text-sm">
206
+ <thead>
207
+ {table.getHeaderGroups().map((headerGroup) => (
208
+ <tr key={headerGroup.id} className="border-b border-border">
209
+ {headerGroup.headers.map((header) => {
210
+ const sort = header.column.getIsSorted();
211
+ return (
212
+ <th
213
+ key={header.id}
214
+ onClick={header.column.getToggleSortingHandler()}
215
+ className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30 sticky top-0 z-10 select-none cursor-pointer hover:bg-muted/50 transition-colors"
216
+ >
217
+ <span className="inline-flex items-center gap-1.5">
218
+ {flexRender(header.column.columnDef.header, header.getContext())}
219
+ <SortIndicator dir={sort} />
220
+ </span>
221
+ </th>
222
+ );
223
+ })}
224
+ </tr>
225
+ ))}
226
+ </thead>
227
+ <tbody>
228
+ {visibleRows.length === 0 ? (
229
+ <tr>
230
+ <td colSpan={columnIds.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
231
+ No rows match the filter.
232
+ </td>
233
+ </tr>
234
+ ) : (
235
+ visibleRows.map((row) => (
236
+ <tr key={row.id} className="border-b border-border/50 hover:bg-muted/20">
237
+ {row.getVisibleCells().map((cell) => (
238
+ <td key={cell.id} className="px-3 py-2 text-foreground/80">
239
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
240
+ </td>
241
+ ))}
242
+ </tr>
243
+ ))
244
+ )}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ </PopoutDialog>
249
+ );
250
+ };
251
+
252
+ // Memoize on meaningful props (block identity, content, open, container).
253
+ // Upstream Viewer re-renders (annotation toolbar opening, selection change,
254
+ // hover state shuffling) keep firing while the popout is mounted. Without
255
+ // this memo, TanStack's flexRender re-evaluates every cell on every parent
256
+ // re-render, which conflicts with web-highlighter's DOM mutations (the
257
+ // library inserts <mark> tags into the live DOM) and React's reconciler
258
+ // throws NotFoundError trying to remove nodes it doesn't own.
259
+ export const TablePopout = React.memo(
260
+ TablePopoutImpl,
261
+ (prev, next) =>
262
+ prev.block.id === next.block.id &&
263
+ prev.block.content === next.block.content &&
264
+ prev.open === next.open &&
265
+ prev.container === next.container &&
266
+ prev.imageBaseDir === next.imageBaseDir &&
267
+ prev.githubRepo === next.githubRepo,
268
+ );
269
+
270
+ const SortIndicator: React.FC<{ dir: false | 'asc' | 'desc' }> = ({ dir }) => {
271
+ const activeUp = dir === 'asc';
272
+ const activeDown = dir === 'desc';
273
+ return (
274
+ <span className="inline-flex flex-col leading-none text-[9px]">
275
+ <span className={activeUp ? 'text-foreground' : 'text-muted-foreground/40'}>▲</span>
276
+ <span className={activeDown ? 'text-foreground' : 'text-muted-foreground/40'}>▼</span>
277
+ </span>
278
+ );
279
+ };