@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.
- package/HANDOFF.md +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/mentions.ts +34 -5
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
312
|
-
aria-label=
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
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}
|