@plannotator/ui 0.52.1 → 0.53.1

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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * The muted line under an Ask AI provider bar that says how to get "Ask this
5
+ * session" (text from askSessionSetupHint). Renders nothing without text.
6
+ */
7
+ export const AskSessionSetupHint: React.FC<{ text?: string | null }> = ({ text }) =>
8
+ text ? (
9
+ <div className="-mt-1 px-2 pb-1.5 text-[10px] text-muted-foreground/70" data-ai-ask-session-setup-hint>
10
+ {text}
11
+ </div>
12
+ ) : null;
@@ -1,6 +1,6 @@
1
1
  import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat';
3
- import type { AIProviderOption } from '../../utils/aiProvider';
3
+ import { hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider';
4
4
  import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat';
5
5
  import { OverlayScrollArea } from '../OverlayScrollArea';
6
6
  import { SparklesIcon } from '../SparklesIcon';
@@ -22,6 +22,8 @@ interface DocumentAIChatPanelProps {
22
22
  onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
23
23
  /** "Ask this session": the reviewer's choice on a busy answer. */
24
24
  onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
25
+ /** Optional note under the provider bar on how to get "Ask this session" (askSessionSetupHint). */
26
+ askSessionSetupHint?: string | null;
25
27
  }
26
28
 
27
29
  function truncate(text: string, max = 180): string {
@@ -68,7 +70,10 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
68
70
  aiConfig,
69
71
  onAIConfigChange,
70
72
  onSessionAskAction,
73
+ askSessionSetupHint,
71
74
  }) => {
75
+ // A session that is never interrupted (T3 Code): Stop only stops listening.
76
+ const listenOnly = hasNonInterruptibleSession(aiProviders);
72
77
  const scrollRef = useRef<HTMLDivElement>(null);
73
78
  const [generalInput, setGeneralInput] = useState('');
74
79
  const latestMessage = messages[messages.length - 1];
@@ -116,6 +121,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
116
121
  <DocumentQAPair
117
122
  key={entry.question.id}
118
123
  entry={entry}
124
+ canInterrupt={!listenOnly}
119
125
  onSessionAskAction={onSessionAskAction}
120
126
  />
121
127
  ))}
@@ -144,6 +150,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
144
150
  onProviderChange={(providerId) => onAIConfigChange?.({ providerId })}
145
151
  onModelChange={(model) => onAIConfigChange?.({ model })}
146
152
  onReasoningEffortChange={(reasoningEffort) => onAIConfigChange?.({ reasoningEffort })}
153
+ setupHint={askSessionSetupHint}
147
154
  />
148
155
 
149
156
  {onAskGeneral && (
@@ -154,6 +161,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
154
161
  disabled={isStreaming}
155
162
  isStreaming={isStreaming}
156
163
  onStop={onStop}
164
+ observationOnly={listenOnly}
157
165
  />
158
166
  )}
159
167
  </div>
@@ -163,7 +171,8 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
163
171
  const DocumentQAPair = memo<{
164
172
  entry: AIChatEntry;
165
173
  onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
166
- }>(({ entry, onSessionAskAction }) => {
174
+ canInterrupt?: boolean;
175
+ }>(({ entry, onSessionAskAction, canInterrupt }) => {
167
176
  const { question, response } = entry;
168
177
  const renderedResponse = useMemo(
169
178
  () => response.text ? renderChatMarkdown(response.text) : null,
@@ -205,6 +214,7 @@ const DocumentQAPair = memo<{
205
214
  <p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
206
215
  <SessionAskActions
207
216
  response={response}
217
+ canInterrupt={canInterrupt}
208
218
  onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
209
219
  />
210
220
  </>
@@ -274,7 +284,8 @@ const GeneralInput: React.FC<{
274
284
  disabled?: boolean;
275
285
  isStreaming?: boolean;
276
286
  onStop?: () => void;
277
- }> = ({ value, onChange, onSubmit, disabled, isStreaming, onStop }) => {
287
+ observationOnly?: boolean;
288
+ }> = ({ value, onChange, onSubmit, disabled, isStreaming, onStop, observationOnly = false }) => {
278
289
  const textareaRef = useRef<HTMLTextAreaElement>(null);
279
290
 
280
291
  useEffect(() => {
@@ -308,9 +319,10 @@ const GeneralInput: React.FC<{
308
319
  <button
309
320
  onClick={onStop}
310
321
  className="p-1.5 mb-px rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
311
- title="Stop generating"
312
- aria-label="Stop generating"
322
+ title={observationOnly ? 'Stop listening; the question keeps running in the session' : 'Stop generating'}
323
+ aria-label={observationOnly ? 'Stop listening' : 'Stop generating'}
313
324
  >
325
+ {observationOnly && <span className="text-[10px] mr-1">Stop listening</span>}
314
326
  <svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
315
327
  <rect x="6" y="6" width="12" height="12" rx="2" />
316
328
  </svg>
@@ -42,7 +42,8 @@ const buttonClass =
42
42
  export const SessionAskActions: React.FC<{
43
43
  response: AIResponse;
44
44
  onAction?: (action: SessionAskAction) => void;
45
- }> = ({ response, onAction }) => {
45
+ canInterrupt?: boolean;
46
+ }> = ({ response, onAction, canInterrupt = true }) => {
46
47
  if (!response.errorCode) return null;
47
48
 
48
49
  if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
@@ -56,14 +57,14 @@ export const SessionAskActions: React.FC<{
56
57
  >
57
58
  Ask when it finishes
58
59
  </button>
59
- <button
60
+ {canInterrupt && <button
60
61
  type="button"
61
62
  onClick={() => onAction('interrupt')}
62
63
  className={`${buttonClass} bg-muted text-foreground hover:bg-muted/80`}
63
64
  title="Stops the session's current work, then asks your question"
64
65
  >
65
66
  Interrupt and ask now
66
- </button>
67
+ </button>}
67
68
  </div>
68
69
  );
69
70
  }
@@ -0,0 +1,159 @@
1
+ import React, { createContext, useContext, useMemo } from 'react';
2
+ import type { Block } from '../../types';
3
+ import { computeListIndices, groupBlocks, parseMarkdownToBlocks } from '../../utils/parser';
4
+ import { isQuestionDirectiveKind } from '@plannotator/core/question-block';
5
+ import { InlineMarkdown } from '../InlineMarkdown';
6
+ import { Callout } from './Callout';
7
+ // BlockRenderer renders this, and this renders the quote's inner blocks with
8
+ // BlockRenderer: a cycle that only meets at render time, after both modules
9
+ // have loaded (the QuestionContext precedent).
10
+ import { BlockRenderer } from '../BlockRenderer';
11
+
12
+ /** The quote's look. Shared by both render paths so they cannot drift. */
13
+ export const BLOCKQUOTE_CLASS_NAME = 'border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic';
14
+
15
+ /**
16
+ * Spacing and tone for a quote whose content has block structure. A
17
+ * stylesheet rather than Tailwind arbitrary variants for the reason
18
+ * QuestionContext gives: no new utilities in every bundle that scans
19
+ * `packages/ui/components`, and a host gets the rules without Tailwind setup.
20
+ * Unlayered, so it outranks the inner renderers' own margins (a code block's
21
+ * `my-5`, a nested quote's `my-4`): children sit flush with the bar's ends
22
+ * and half a line apart, like the paragraphs of a prose quote.
23
+ */
24
+ const QUOTE_BODY_CSS = `
25
+ .pn-quote-body > * { margin-top: 0; margin-bottom: 0; }
26
+ .pn-quote-body > * + * { margin-top: 0.5rem; }
27
+ .pn-quote-body pre, .pn-quote-body table { font-style: normal; }
28
+ .pn-quote-body [data-quote-list] span { color: inherit; }
29
+ `;
30
+
31
+ /** The quote's inner markdown, split the way the document is split. */
32
+ export const parseQuoteBody = (content: string): Block[] =>
33
+ parseMarkdownToBlocks(content, { frontmatter: false });
34
+
35
+ /**
36
+ * Whether a quote's inner blocks are only paragraphs. Such a quote renders
37
+ * exactly as quotes always have (one `<p>` per paragraph, nothing else); any
38
+ * other block (a code fence, a list, a nested quote, a table, a heading…)
39
+ * takes the structured path.
40
+ */
41
+ export const isProseQuote = (inner: readonly Block[]): boolean =>
42
+ inner.every((block) => block.type === 'paragraph');
43
+
44
+ /**
45
+ * How deep quotes may nest before the rest renders as text. Each level is a
46
+ * component and a parse of its own, so `>>>>…` thousands deep (a third-party
47
+ * PR comment can hold anything) would otherwise overflow the stack and
48
+ * unmount the tree. GitHub stops drawing distinct levels long before this.
49
+ */
50
+ export const MAX_QUOTE_DEPTH = 12;
51
+
52
+ /** The number of quotes around the one rendering (0 at the top). */
53
+ const QuoteDepthContext = createContext(0);
54
+
55
+ export interface BlockquoteBlockProps {
56
+ block: Block;
57
+ onOpenLinkedDoc?: (path: string) => void;
58
+ onOpenCodeFile?: (path: string) => void;
59
+ imageBaseDir?: string;
60
+ onImageClick?: (src: string, alt: string) => void;
61
+ githubRepo?: string;
62
+ repoHost?: string;
63
+ onNavigateAnchor?: (hash: string) => void;
64
+ }
65
+
66
+ /**
67
+ * A (non-alert) blockquote: one `<blockquote>` with one bar, carrying the
68
+ * block's id, whatever its content holds.
69
+ *
70
+ * The inner blocks belong to the quote. They render through the document's
71
+ * own renderers but carry no `data-block-id`, so a selection, a pinpoint, a
72
+ * restore or an export inside them resolves to the quote block (the
73
+ * QuestionContext rule). A `:::question` written inside a quote is not a
74
+ * question block (`findQuestionBlocks` agrees): it renders as a callout.
75
+ */
76
+ export const BlockquoteBlock: React.FC<BlockquoteBlockProps> = ({ block, ...inlineProps }) => {
77
+ const depth = useContext(QuoteDepthContext);
78
+ // Past the cap the content is not parsed again: it renders as prose text,
79
+ // its remaining `>` markers included.
80
+ const atCap = depth + 1 >= MAX_QUOTE_DEPTH;
81
+ const inner = useMemo(() => (atCap ? [] : parseQuoteBody(block.content)), [atCap, block.content]);
82
+
83
+ if (atCap || isProseQuote(inner)) {
84
+ // The prose quote, unchanged: split on blank-line paragraph breaks so
85
+ // `> a\n>\n> b` renders as two <p> children. Lines are trimmed as the
86
+ // parser used to trim them, so the text is what it always was.
87
+ const paragraphs = block.content
88
+ .split('\n')
89
+ .map((line) => line.trim())
90
+ .join('\n')
91
+ .split(/\n\n+/);
92
+ return (
93
+ <blockquote className={BLOCKQUOTE_CLASS_NAME} data-block-id={block.id}>
94
+ {paragraphs.map((para, i) => (
95
+ <p key={i} className={i > 0 ? 'mt-2' : ''}>
96
+ <InlineMarkdown text={para} {...inlineProps} />
97
+ </p>
98
+ ))}
99
+ </blockquote>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <blockquote className={`${BLOCKQUOTE_CLASS_NAME} pn-quote-body`} data-block-id={block.id}>
105
+ {/* React hoists this into <head> once for every quote. */}
106
+ <style href="plannotator-quote-body" precedence="default">{QUOTE_BODY_CSS}</style>
107
+ <QuoteDepthContext.Provider value={depth + 1}>
108
+ <QuoteBody inner={inner} {...inlineProps} />
109
+ </QuoteDepthContext.Provider>
110
+ </blockquote>
111
+ );
112
+ };
113
+
114
+ /** Ids leave after grouping (grouping keys on them); undefined makes the
115
+ * renderers omit `data-block-id`. */
116
+ const anonymous = (block: Block): Block => ({ ...block, id: undefined as unknown as string });
117
+
118
+ const QuoteBody: React.FC<Omit<BlockquoteBlockProps, 'block'> & { inner: Block[] }> = ({ inner, ...inlineProps }) => (
119
+ <>
120
+ {groupBlocks(inner).map((group, i) => {
121
+ if (group.type === 'list-group') {
122
+ const indices = computeListIndices(group.blocks);
123
+ return (
124
+ <div key={`l-${i}`} data-quote-list="">
125
+ {group.blocks.map((block, j) => (
126
+ <BlockRenderer key={j} block={anonymous(block)} orderedIndex={indices[j]} {...inlineProps} />
127
+ ))}
128
+ </div>
129
+ );
130
+ }
131
+ const block = group.block;
132
+ if (block.type === 'paragraph') {
133
+ // The prose quote's paragraph, so text reads the same with or
134
+ // without a fence beside it.
135
+ return (
136
+ <p key={`b-${i}`}>
137
+ <InlineMarkdown text={block.content} {...inlineProps} />
138
+ </p>
139
+ );
140
+ }
141
+ if (block.type === 'directive' && isQuestionDirectiveKind(block.directiveKind)) {
142
+ const kind = block.directiveKind || 'note';
143
+ return (
144
+ <Callout
145
+ key={`b-${i}`}
146
+ blockId={undefined as unknown as string}
147
+ kind={kind}
148
+ body={block.content}
149
+ containerClassName={`directive directive-${kind} my-4 px-4 py-3 rounded-md border`}
150
+ blockType="directive"
151
+ kindAttribute={kind}
152
+ {...inlineProps}
153
+ />
154
+ );
155
+ }
156
+ return <BlockRenderer key={`b-${i}`} block={anonymous(block)} {...inlineProps} />;
157
+ })}
158
+ </>
159
+ );
@@ -3,6 +3,8 @@ import { isCodeFilePath } from "@plannotator/core/code-file";
3
3
  import type { Block } from "../../types";
4
4
  import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
5
5
  import { getImageSrc } from "../ImageThumbnail";
6
+ import { UNAVAILABLE_IMAGE_CLASS, useImageResolver, type ImageResolver } from "../ImageResolverContext";
7
+ import { isLocalLinkTarget, isNewTabClick, useLocalLinkResolver, type LocalLinkResolver } from "../LocalLinkContext";
6
8
 
7
9
  interface HtmlBlockProps {
8
10
  block: Block;
@@ -12,6 +14,56 @@ interface HtmlBlockProps {
12
14
  onNavigateAnchor?: (hash: string) => void;
13
15
  }
14
16
 
17
+ function unavailableMedia(doc: Document, label: string, title: string): HTMLSpanElement {
18
+ const placeholder = doc.createElement('span');
19
+ placeholder.className = UNAVAILABLE_IMAGE_CLASS;
20
+ placeholder.title = title;
21
+ placeholder.setAttribute('data-image-unavailable', '');
22
+ placeholder.textContent = label;
23
+ return placeholder;
24
+ }
25
+
26
+ /**
27
+ * With a host image resolver (RenderedMarkdown `resolveImage`), every media
28
+ * URL is the host's call, decided on an INERT copy before anything is in the
29
+ * document, so nothing is fetched from the page origin first:
30
+ * - `<img src>` goes through the resolver (`src`, or a placeholder); its
31
+ * `srcset` / `sizes` are dropped, so the browser cannot pick a candidate
32
+ * the host never saw.
33
+ * - `<picture><source>` is dropped; the `<img>` inside is the fallback.
34
+ * - `<video>` (with its `src`, `poster` and `<source>`s) becomes a
35
+ * placeholder: the resolver speaks for images only.
36
+ */
37
+ function resolveHostMedia(root: DocumentFragment, doc: Document, resolveImage: ImageResolver): void {
38
+ root.querySelectorAll('video').forEach((video) => {
39
+ video.replaceWith(unavailableMedia(doc, 'Video', 'Videos are not shown here'));
40
+ });
41
+ root.querySelectorAll('picture source').forEach((source) => source.remove());
42
+ root.querySelectorAll('img').forEach((img) => {
43
+ img.removeAttribute('srcset');
44
+ img.removeAttribute('sizes');
45
+ const src = img.getAttribute('src');
46
+ const alt = img.getAttribute('alt') || 'Image';
47
+ if (!src) {
48
+ img.replaceWith(unavailableMedia(doc, alt, 'Image has no source'));
49
+ return;
50
+ }
51
+ const resolved = resolveImage(src);
52
+ if (resolved?.kind === 'unavailable') {
53
+ img.replaceWith(unavailableMedia(doc, alt, resolved.title));
54
+ return;
55
+ }
56
+ if (resolved?.kind === 'src') {
57
+ img.setAttribute('src', resolved.src);
58
+ } else if (!/^(https?:|data:|blob:)/i.test(src)) {
59
+ img.setAttribute('src', getImageSrc(src));
60
+ }
61
+ img.setAttribute('data-host-image', '');
62
+ });
63
+ }
64
+
65
+ const IMAGE_NOT_FOUND_TITLE = 'Image could not be loaded';
66
+
15
67
  // Walks the sanitized DOM and rewrites relative <img src> / <a href> so they
16
68
  // behave the same as their markdown counterparts:
17
69
  // - Relative image paths route through /api/image?path=... so they load from
@@ -25,15 +77,27 @@ function rewriteRelativeRefs(
25
77
  onOpenLinkedDoc?: (path: string) => void,
26
78
  onOpenCodeFile?: (path: string) => void,
27
79
  onNavigateAnchor?: (hash: string) => void,
80
+ resolveLocalLink?: LocalLinkResolver | null,
81
+ resolveImage?: ImageResolver | null,
28
82
  ): (() => void) {
29
83
  const cleanups: (() => void)[] = [];
30
84
 
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
- });
85
+ if (resolveImage) {
86
+ // Media was already decided by the host before insertion (see
87
+ // resolveHostMedia); only the load-failure fallback is wired here.
88
+ root.querySelectorAll<HTMLImageElement>('img[data-host-image]').forEach((img) => {
89
+ const onError = () => img.replaceWith(unavailableMedia(img.ownerDocument, img.getAttribute('alt') || 'Image', IMAGE_NOT_FOUND_TITLE));
90
+ img.addEventListener('error', onError);
91
+ cleanups.push(() => img.removeEventListener('error', onError));
92
+ });
93
+ } else {
94
+ root.querySelectorAll('img').forEach((img) => {
95
+ const src = img.getAttribute('src');
96
+ if (!src) return;
97
+ if (/^(https?:|data:|blob:)/i.test(src)) return;
98
+ img.setAttribute('src', getImageSrc(src, imageBaseDir));
99
+ });
100
+ }
37
101
 
38
102
  root.querySelectorAll('a').forEach((a) => {
39
103
  const href = a.getAttribute('href');
@@ -59,6 +123,45 @@ function rewriteRelativeRefs(
59
123
  cleanups.push(() => a.removeEventListener('click', handler));
60
124
  return;
61
125
  }
126
+ // A host's resolver (Viewer `resolveLocalLink`) decides local paths
127
+ // first, as InlineMarkdown does; null keeps the rules below.
128
+ const localLink = resolveLocalLink && isLocalLinkTarget(href) ? resolveLocalLink(href) : null;
129
+ if (localLink?.kind === 'open') {
130
+ const openLink = localLink.open;
131
+ const hostHref = localLink.href;
132
+ const handler = (e: Event) => {
133
+ // With the host's own address, a new-tab click goes there.
134
+ if (hostHref && e instanceof MouseEvent && isNewTabClick(e)) return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ openLink();
138
+ };
139
+ if (hostHref) {
140
+ a.setAttribute('href', hostHref);
141
+ a.removeAttribute('target');
142
+ }
143
+ if (localLink.title) a.setAttribute('title', localLink.title);
144
+ a.setAttribute('data-local-link', 'open');
145
+ // Without it, a middle click would open the path on the host's own origin.
146
+ const aux = (e: Event) => {
147
+ if (!hostHref) e.preventDefault();
148
+ };
149
+ a.addEventListener('click', handler);
150
+ a.addEventListener('auxclick', aux);
151
+ cleanups.push(() => {
152
+ a.removeEventListener('click', handler);
153
+ a.removeEventListener('auxclick', aux);
154
+ });
155
+ return;
156
+ }
157
+ if (localLink?.kind === 'unavailable') {
158
+ // Inert: without an href the browser neither follows nor fetches it.
159
+ a.removeAttribute('href');
160
+ a.removeAttribute('target');
161
+ a.setAttribute('title', localLink.title);
162
+ a.setAttribute('data-local-link', 'unavailable');
163
+ return;
164
+ }
62
165
  if (onOpenCodeFile && isCodeFilePath(href)) {
63
166
  const handler = (e: Event) => {
64
167
  e.preventDefault();
@@ -89,18 +192,27 @@ function rewriteRelativeRefs(
89
192
  // stops re-rendering unless the block content actually changes.
90
193
  const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor }) => {
91
194
  const ref = useRef<HTMLDivElement>(null);
195
+ const resolveLocalLink = useLocalLinkResolver();
196
+ const resolveImage = useImageResolver();
92
197
  const sanitized = React.useMemo(
93
198
  () => sanitizeBlockHtml(block.content),
94
199
  [block.content],
95
200
  );
96
201
  useEffect(() => {
97
202
  if (!ref.current) return;
98
- if (ref.current.innerHTML !== sanitized) {
203
+ if (resolveImage) {
204
+ // Decide every media URL on an inert template first (nothing loads
205
+ // there), then insert the result.
206
+ const template = ref.current.ownerDocument.createElement('template');
207
+ template.innerHTML = sanitized;
208
+ resolveHostMedia(template.content, ref.current.ownerDocument, resolveImage);
209
+ ref.current.replaceChildren(template.content);
210
+ } else if (ref.current.innerHTML !== sanitized) {
99
211
  ref.current.innerHTML = sanitized;
100
212
  }
101
- const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor);
213
+ const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, resolveLocalLink, resolveImage);
102
214
  return cleanup;
103
- }, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor]);
215
+ }, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, resolveLocalLink, resolveImage]);
104
216
  return (
105
217
  <div
106
218
  ref={ref}