@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.
- package/HANDOFF.md +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ArrowUpRight, Play } from 'lucide-react';
|
|
3
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time announcement for the Plannotator Inbox, on plan review, annotate
|
|
7
|
+
* and code review. Nearly full screen: the Inbox film from plannotator.ai/inbox
|
|
8
|
+
* fills the top, under it one title, one sentence, "Close" and one link to the
|
|
9
|
+
* Inbox page.
|
|
10
|
+
*
|
|
11
|
+
* Built on the shared dialog (`./ui/dialog`, Base UI): modal, focus trapped,
|
|
12
|
+
* focus restored on close, the backdrop and Escape close it. Like the other
|
|
13
|
+
* announcements it swallows Mod+Enter on the capture phase, so a keystroke
|
|
14
|
+
* aimed at it cannot approve a plan or post a review behind it.
|
|
15
|
+
*
|
|
16
|
+
* The film is the marketing page's own file (`apps/marketing/public/assets/
|
|
17
|
+
* inbox-film.mp4`, 1920x1080, ~14 MB, voice-led), served from
|
|
18
|
+
* plannotator.ai/assets like the other announcements' footage. It never
|
|
19
|
+
* autoplays and is `preload="none"`: opening the dialog fetches only the poster
|
|
20
|
+
* (~120 KB), and the film downloads when the reader presses play. Until then
|
|
21
|
+
* the frame is the poster with one play button, as on the Inbox page; after
|
|
22
|
+
* that the native controls take over (sound, seeking, full screen). If the
|
|
23
|
+
* film cannot load, the frame says so and the rest of the dialog is unchanged.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The Inbox page on plannotator.ai. */
|
|
27
|
+
export const INBOX_PAGE_URL = 'https://plannotator.ai/inbox';
|
|
28
|
+
|
|
29
|
+
/** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
|
|
30
|
+
const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
|
|
31
|
+
|
|
32
|
+
export const INBOX_FILM = {
|
|
33
|
+
mp4: `${MEDIA_BASE_URL}/inbox-film.mp4`,
|
|
34
|
+
poster: `${MEDIA_BASE_URL}/inbox-film-poster.jpg`,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** The film is 1920x1080. */
|
|
38
|
+
const FILM_ASPECT = 16 / 9;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The panel's width also follows the viewport height, so on a short window the
|
|
42
|
+
* film shrinks until film and footer fit instead of scrolling the film away.
|
|
43
|
+
* 2rem is the overlay's padding, 10rem the footer on a wide panel.
|
|
44
|
+
*/
|
|
45
|
+
const PANEL_WIDTH =
|
|
46
|
+
`min(1200px, 100%, calc((var(--pn-viewport-height, 100dvh) - 2rem - 10rem) * ${FILM_ASPECT}))`;
|
|
47
|
+
|
|
48
|
+
interface InboxAnnouncementDialogProps {
|
|
49
|
+
readonly isOpen: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Marks the announcement seen and closes it. Wired to Close, Escape, the
|
|
52
|
+
* backdrop and the link to the Inbox page.
|
|
53
|
+
*/
|
|
54
|
+
readonly onDismiss: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Film() {
|
|
58
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
59
|
+
const [started, setStarted] = useState(false);
|
|
60
|
+
const [failed, setFailed] = useState(false);
|
|
61
|
+
|
|
62
|
+
const play = () => {
|
|
63
|
+
setStarted(true);
|
|
64
|
+
// A rejected play() (autoplay policy, no media) leaves the native
|
|
65
|
+
// controls up, which offer play again.
|
|
66
|
+
void videoRef.current?.play()?.catch(() => {});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="relative w-full shrink-0 overflow-hidden border-b border-border bg-muted" style={{ aspectRatio: String(FILM_ASPECT) }}>
|
|
71
|
+
<video
|
|
72
|
+
ref={videoRef}
|
|
73
|
+
data-inbox-film
|
|
74
|
+
poster={INBOX_FILM.poster}
|
|
75
|
+
controls={started}
|
|
76
|
+
playsInline
|
|
77
|
+
preload="none"
|
|
78
|
+
aria-label="The Plannotator Inbox film"
|
|
79
|
+
// A source that cannot be fetched errors on <source>; a file that was
|
|
80
|
+
// fetched but cannot be decoded errors on the element itself.
|
|
81
|
+
onError={() => setFailed(true)}
|
|
82
|
+
className="absolute inset-0 h-full w-full bg-muted object-contain"
|
|
83
|
+
>
|
|
84
|
+
<source src={INBOX_FILM.mp4} type="video/mp4" onError={() => setFailed(true)} />
|
|
85
|
+
</video>
|
|
86
|
+
|
|
87
|
+
{failed ? (
|
|
88
|
+
<p
|
|
89
|
+
data-inbox-film-unavailable
|
|
90
|
+
className="absolute inset-0 grid place-items-center bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
|
|
91
|
+
>
|
|
92
|
+
The film could not load. It is also on the Inbox page.
|
|
93
|
+
</p>
|
|
94
|
+
) : !started ? (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={play}
|
|
98
|
+
aria-label="Play the Inbox film"
|
|
99
|
+
data-inbox-film-play
|
|
100
|
+
className="group absolute inset-0 grid place-items-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
101
|
+
>
|
|
102
|
+
<span className="grid size-16 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg backdrop-blur-sm transition-transform motion-reduce:transition-none group-hover:scale-105 sm:size-20">
|
|
103
|
+
<Play className="ml-1 size-6 fill-current sm:size-7" aria-hidden="true" />
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function InboxAnnouncementDialog({ isOpen, onDismiss }: InboxAnnouncementDialogProps) {
|
|
112
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
113
|
+
const onDismissRef = useRef(onDismiss);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
onDismissRef.current = onDismiss;
|
|
117
|
+
}, [onDismiss]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!isOpen) return;
|
|
121
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
122
|
+
// Escape is handled here rather than by the dialog so that no window
|
|
123
|
+
// handler of the App behind it (close a panel, leave a mode) sees it.
|
|
124
|
+
if (event.key === 'Escape') {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
event.stopPropagation();
|
|
127
|
+
onDismissRef.current();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Both apps submit their decision on Mod+Enter from a window-level
|
|
131
|
+
// handler; swallow it on the capture phase.
|
|
132
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
event.stopPropagation();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
138
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
139
|
+
}, [isOpen]);
|
|
140
|
+
|
|
141
|
+
if (!isOpen) return null;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Dialog
|
|
145
|
+
open
|
|
146
|
+
onOpenChange={(open) => {
|
|
147
|
+
if (!open) onDismissRef.current();
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<DialogContent
|
|
151
|
+
hideClose
|
|
152
|
+
initialFocus={closeRef}
|
|
153
|
+
data-inbox-announcement-dialog
|
|
154
|
+
backdropClassName="bg-background/90 backdrop-blur-sm"
|
|
155
|
+
style={{ width: PANEL_WIDTH }}
|
|
156
|
+
className="max-h-full max-w-none rounded-xl bg-card text-card-foreground shadow-2xl motion-reduce:transition-none"
|
|
157
|
+
>
|
|
158
|
+
<Film />
|
|
159
|
+
|
|
160
|
+
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto px-5 py-5 sm:flex-row sm:items-end sm:justify-between sm:gap-8 sm:px-7 sm:py-6">
|
|
161
|
+
<div className="min-w-0">
|
|
162
|
+
<DialogTitle className="text-balance text-xl font-semibold tracking-tight sm:text-2xl">
|
|
163
|
+
The Plannotator Inbox
|
|
164
|
+
</DialogTitle>
|
|
165
|
+
<DialogDescription className="mt-1.5 max-w-xl text-pretty leading-relaxed">
|
|
166
|
+
Questions, blockers and reviews from your agents arrive in one inbox while they work,
|
|
167
|
+
and your answers go back to them.
|
|
168
|
+
</DialogDescription>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="flex shrink-0 flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
|
172
|
+
<button
|
|
173
|
+
ref={closeRef}
|
|
174
|
+
type="button"
|
|
175
|
+
data-inbox-announcement-close
|
|
176
|
+
onClick={() => onDismissRef.current()}
|
|
177
|
+
className="min-h-10 rounded-lg border border-border px-4 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
178
|
+
>
|
|
179
|
+
Close
|
|
180
|
+
</button>
|
|
181
|
+
<a
|
|
182
|
+
href={INBOX_PAGE_URL}
|
|
183
|
+
target="_blank"
|
|
184
|
+
rel="noopener noreferrer"
|
|
185
|
+
data-inbox-announcement-learn-more
|
|
186
|
+
// Following the link counts as seeing the announcement. Closed on
|
|
187
|
+
// the next task: a link removed from the page during its own click
|
|
188
|
+
// is not followed.
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setTimeout(() => onDismissRef.current(), 0);
|
|
191
|
+
}}
|
|
192
|
+
className="inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
193
|
+
>
|
|
194
|
+
Learn about the Inbox
|
|
195
|
+
<ArrowUpRight className="size-4" aria-hidden="true" />
|
|
196
|
+
</a>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</DialogContent>
|
|
200
|
+
</Dialog>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import React, { useState,
|
|
2
|
-
import { createPortal } from "react-dom";
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
3
2
|
import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
|
|
4
|
-
import { forgeRefLinks } from "@plannotator/core/forge-refs";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { findCrossRepoRef, forgeRefLinks, matchCrossRepoRef, type CrossRepoRef } from "@plannotator/core/forge-refs";
|
|
4
|
+
import { CodeSnippetPreview } from "./CodeSnippetPreview";
|
|
5
|
+
import { useHoverPreview } from "../hooks/useHoverPreview";
|
|
7
6
|
import { transformPlainText } from "../utils/inlineTransforms";
|
|
8
7
|
import { hasLinkedDocExtension } from "../utils/markdownExtensions";
|
|
9
8
|
import { getImageSrc } from "./ImageThumbnail";
|
|
@@ -77,101 +76,6 @@ function gateCodePath(
|
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
function extToLanguage(filepath: string): string | undefined {
|
|
81
|
-
const ext = filepath.split('.').pop()?.toLowerCase();
|
|
82
|
-
const map: Record<string, string> = {
|
|
83
|
-
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
84
|
-
py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
|
|
85
|
-
css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
|
|
86
|
-
sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
|
|
87
|
-
html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
|
|
88
|
-
};
|
|
89
|
-
return ext ? map[ext] : undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const CodeSnippetPreview: React.FC<{
|
|
93
|
-
anchorEl: HTMLElement | null;
|
|
94
|
-
contents: string;
|
|
95
|
-
filepath: string;
|
|
96
|
-
line: number;
|
|
97
|
-
lineEnd?: number;
|
|
98
|
-
onMouseEnter?: () => void;
|
|
99
|
-
onMouseLeave?: () => void;
|
|
100
|
-
}> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => {
|
|
101
|
-
const allLines = contents.split('\n');
|
|
102
|
-
const start = Math.max(0, line - 1);
|
|
103
|
-
const end = Math.min(allLines.length, (lineEnd ?? line));
|
|
104
|
-
const snippet = allLines.slice(start, end).join('\n');
|
|
105
|
-
|
|
106
|
-
const fenceTheme = useFenceTheme();
|
|
107
|
-
// Bumped once the grammar is attached, to re-run the memo below with the
|
|
108
|
-
// highlighter warm. Until then the snippet renders as plain text — no
|
|
109
|
-
// auto-detection, and an unknown extension simply stays plain.
|
|
110
|
-
const [highlighterGeneration, setHighlighterGeneration] = useState(0);
|
|
111
|
-
const lang = extToLanguage(filepath);
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (!lang) return;
|
|
115
|
-
let cancelled = false;
|
|
116
|
-
void ensureHighlight(lang, fenceTheme).then((ok) => {
|
|
117
|
-
if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
|
|
118
|
-
});
|
|
119
|
-
return () => { cancelled = true; };
|
|
120
|
-
}, [lang, fenceTheme]);
|
|
121
|
-
|
|
122
|
-
const highlightedLines = useMemo(() => {
|
|
123
|
-
// Highlight the snippet as one unit so multi-line constructs (block
|
|
124
|
-
// comments, template literals) tokenise correctly, then split back into
|
|
125
|
-
// rows: the highlighter joins lines with "\n" and never emits one inside a
|
|
126
|
-
// span, so the split is exact.
|
|
127
|
-
const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null;
|
|
128
|
-
if (html !== null) return html.split('\n');
|
|
129
|
-
return snippet.split('\n').map(line =>
|
|
130
|
-
line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
|
|
131
|
-
// highlighterGeneration is the "grammar just became available" signal.
|
|
132
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
-
}, [snippet, lang, fenceTheme, highlighterGeneration]);
|
|
134
|
-
|
|
135
|
-
if (!anchorEl) return null;
|
|
136
|
-
|
|
137
|
-
const rect = anchorEl.getBoundingClientRect();
|
|
138
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
139
|
-
const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
|
|
140
|
-
const top = showAbove ? undefined : rect.bottom + 4;
|
|
141
|
-
const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
|
|
142
|
-
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520));
|
|
143
|
-
|
|
144
|
-
return createPortal(
|
|
145
|
-
<div
|
|
146
|
-
className="fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
|
|
147
|
-
style={{ top, bottom, left, maxWidth: 'min(600px, 90vw)', maxHeight: '300px' }}
|
|
148
|
-
onMouseEnter={onMouseEnter}
|
|
149
|
-
onMouseLeave={onMouseLeave}
|
|
150
|
-
>
|
|
151
|
-
<div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
|
|
152
|
-
<span>{filepath.split('/').pop()}</span>
|
|
153
|
-
<span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
|
|
154
|
-
</div>
|
|
155
|
-
<div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
|
|
156
|
-
<table className="border-collapse w-full">
|
|
157
|
-
<tbody>
|
|
158
|
-
{snippet.split('\n').map((_, i) => (
|
|
159
|
-
<tr key={start + i} className="hover:bg-white/5">
|
|
160
|
-
<td className="select-none text-muted-foreground/40 text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap" style={{ userSelect: 'none' }}>{start + i + 1}</td>
|
|
161
|
-
<td
|
|
162
|
-
className="font-mono pr-3 py-0 whitespace-pre"
|
|
163
|
-
dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
|
|
164
|
-
/>
|
|
165
|
-
</tr>
|
|
166
|
-
))}
|
|
167
|
-
</tbody>
|
|
168
|
-
</table>
|
|
169
|
-
</div>
|
|
170
|
-
</div>,
|
|
171
|
-
document.body,
|
|
172
|
-
);
|
|
173
|
-
};
|
|
174
|
-
|
|
175
79
|
const CodeFileLink: React.FC<{
|
|
176
80
|
candidate: string;
|
|
177
81
|
display: string;
|
|
@@ -181,57 +85,16 @@ const CodeFileLink: React.FC<{
|
|
|
181
85
|
const validation = useCodePathValidation();
|
|
182
86
|
const gate = gateCodePath(candidate, validation);
|
|
183
87
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
184
|
-
const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null);
|
|
185
|
-
const hoverPreviewRef = useRef(hoverPreview);
|
|
186
|
-
hoverPreviewRef.current = hoverPreview;
|
|
187
|
-
const anchorRef = useRef<HTMLElement | null>(null);
|
|
188
|
-
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
189
|
-
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
190
88
|
const parsed = parseCodePath(candidate);
|
|
191
89
|
const hasLineRef = parsed.line != null;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
setHoverPreview(null);
|
|
201
|
-
}, 200);
|
|
202
|
-
}, [cancelHide]);
|
|
203
|
-
|
|
204
|
-
const handleMouseEnter = useCallback(() => {
|
|
205
|
-
if (!hasLineRef || gate.render === 'plain') return;
|
|
206
|
-
cancelHide();
|
|
207
|
-
if (hoverPreviewRef.current) return;
|
|
208
|
-
showTimerRef.current = setTimeout(async () => {
|
|
209
|
-
try {
|
|
210
|
-
const data = await docPreviewFetcher(candidate, baseDir);
|
|
211
|
-
if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate });
|
|
212
|
-
} catch {}
|
|
213
|
-
}, 150);
|
|
214
|
-
}, [candidate, hasLineRef, gate.render, cancelHide, baseDir]);
|
|
215
|
-
|
|
216
|
-
const handleMouseLeave = useCallback(() => {
|
|
217
|
-
if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
|
|
218
|
-
scheduleHide();
|
|
219
|
-
}, [scheduleHide]);
|
|
220
|
-
|
|
221
|
-
const handlePreviewEnter = useCallback(() => {
|
|
222
|
-
cancelHide();
|
|
223
|
-
}, [cancelHide]);
|
|
224
|
-
|
|
225
|
-
const handlePreviewLeave = useCallback(() => {
|
|
226
|
-
scheduleHide();
|
|
227
|
-
}, [scheduleHide]);
|
|
228
|
-
|
|
229
|
-
useEffect(() => {
|
|
230
|
-
return () => {
|
|
231
|
-
if (showTimerRef.current) clearTimeout(showTimerRef.current);
|
|
232
|
-
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
233
|
-
};
|
|
234
|
-
}, []);
|
|
90
|
+
const hover = useHoverPreview<{ contents: string; filepath: string }>(async () => {
|
|
91
|
+
const data = await docPreviewFetcher(candidate, baseDir);
|
|
92
|
+
return data?.contents ? { contents: data.contents, filepath: data.filepath ?? candidate } : null;
|
|
93
|
+
}, hasLineRef && gate.render !== 'plain');
|
|
94
|
+
const hoverPreview = hover.preview;
|
|
95
|
+
const anchorRef = hover.anchorRef;
|
|
96
|
+
const handleMouseEnter = hover.onEnter;
|
|
97
|
+
const handleMouseLeave = hover.onLeave;
|
|
235
98
|
|
|
236
99
|
if (gate.render === 'plain') {
|
|
237
100
|
return (
|
|
@@ -280,8 +143,7 @@ const CodeFileLink: React.FC<{
|
|
|
280
143
|
filepath={hoverPreview.filepath}
|
|
281
144
|
line={parsed.line!}
|
|
282
145
|
lineEnd={parsed.lineEnd}
|
|
283
|
-
|
|
284
|
-
onMouseLeave={handlePreviewLeave}
|
|
146
|
+
{...hover.previewHandlers}
|
|
285
147
|
/>
|
|
286
148
|
)}
|
|
287
149
|
{pickerOpen && isAmbiguous && (
|
|
@@ -350,6 +212,9 @@ function canRenderSpacedDollarMath(tex: string): boolean {
|
|
|
350
212
|
const trimmed = normalizeMathTex(tex);
|
|
351
213
|
if (!trimmed) return false;
|
|
352
214
|
if (!/^\s|\s$/.test(tex)) return true;
|
|
215
|
+
// Padding must be symmetric (`$ y $`). In `$write-better on the copy, and $`
|
|
216
|
+
// the closing `$` is really the opener of the next `$skill` token.
|
|
217
|
+
if (/^\s/.test(tex) !== /\s$/.test(tex)) return false;
|
|
353
218
|
return (
|
|
354
219
|
/\\[a-zA-Z]+/.test(trimmed) ||
|
|
355
220
|
/[=^_{}+\-*/<>|]/.test(trimmed) ||
|
|
@@ -380,6 +245,34 @@ export function trimUrlTail(url: string): string {
|
|
|
380
245
|
return url;
|
|
381
246
|
}
|
|
382
247
|
|
|
248
|
+
// Index of the first bare `http(s)://` URL that starts after index 0 and
|
|
249
|
+
// before `end` at a word boundary AND runs past `end`, or -1. Only such a
|
|
250
|
+
// URL needs the chunk split: one that ends inside the chunk is linked by
|
|
251
|
+
// emitPlainTextWithBareUrls in the same pass, exactly as before, so a
|
|
252
|
+
// paragraph of many URLs stays one pass instead of a loop turn (and a
|
|
253
|
+
// rescan of the rest of the text) per URL. Index 0 is left to the caller's
|
|
254
|
+
// own bare-URL check, which already ran for it.
|
|
255
|
+
const BARE_URL_SCHEME = /https?:\/\//g;
|
|
256
|
+
const BARE_URL_AT = /https?:\/\/[^\s<>"']+/y;
|
|
257
|
+
function bareUrlRunningPast(text: string, end: number): number {
|
|
258
|
+
// Only a scheme that starts before `end` counts, so read no further than
|
|
259
|
+
// `end` plus the longest scheme ("https://" less its first character).
|
|
260
|
+
const window = text.slice(0, end + 7);
|
|
261
|
+
BARE_URL_SCHEME.lastIndex = 1;
|
|
262
|
+
let m: RegExpExecArray | null;
|
|
263
|
+
while ((m = BARE_URL_SCHEME.exec(window)) !== null && m.index < end) {
|
|
264
|
+
if (/\w/.test(window[m.index - 1])) continue;
|
|
265
|
+
BARE_URL_AT.lastIndex = m.index;
|
|
266
|
+
const url = BARE_URL_AT.exec(text);
|
|
267
|
+
if (!url) continue;
|
|
268
|
+
const urlEnd = m.index + trimUrlTail(url[0]).length;
|
|
269
|
+
if (urlEnd > end) return m.index;
|
|
270
|
+
// The URL ends inside the chunk; look for the next one after it.
|
|
271
|
+
BARE_URL_SCHEME.lastIndex = Math.max(urlEnd, m.index + 1);
|
|
272
|
+
}
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
|
|
383
276
|
// Scan a plain-text chunk for bare https?:// URLs and bare code file paths
|
|
384
277
|
// at word boundaries, emitting them as interactive nodes. Surrounding text
|
|
385
278
|
// passes through transformPlainText for emoji shortcodes + smart punctuation.
|
|
@@ -474,35 +367,73 @@ function emitPlainTextWithBareUrls(
|
|
|
474
367
|
}
|
|
475
368
|
}
|
|
476
369
|
|
|
477
|
-
/**
|
|
370
|
+
/**
|
|
371
|
+
* An image a host resolver chose; a load failure shows the placeholder.
|
|
372
|
+
*
|
|
373
|
+
* The `<img>` element itself never leaves the document: on failure the
|
|
374
|
+
* placeholder is inserted BEFORE it (React's reference node is then the
|
|
375
|
+
* `<img>`, which it owns) and the `<img>` is hidden. Replacing the `<img>`
|
|
376
|
+
* would make React insert before the node that follows it, and when that is
|
|
377
|
+
* a text node the annotation highlighter already moved into a `<mark>` (a
|
|
378
|
+
* restored comment starting right after the image), `insertBefore` throws
|
|
379
|
+
* and the whole document unmounts. A loaded image's DOM is unchanged.
|
|
380
|
+
*/
|
|
478
381
|
const HostResolvedImage: React.FC<{
|
|
479
382
|
src: string;
|
|
480
383
|
alt: string;
|
|
384
|
+
/** The src as written in the markdown (`data-image-src`, read by the image viewer's region comments). */
|
|
385
|
+
writtenSrc?: string;
|
|
481
386
|
onImageClick?: (src: string, alt: string) => void;
|
|
482
|
-
}> = ({ src, alt, onImageClick }) => {
|
|
387
|
+
}> = ({ src, alt, writtenSrc, onImageClick }) => {
|
|
483
388
|
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
484
|
-
|
|
485
|
-
return (
|
|
486
|
-
<span className={UNAVAILABLE_IMAGE_CLASS} title="Image could not be loaded" data-image-unavailable="">
|
|
487
|
-
{alt || 'Image'}
|
|
488
|
-
</span>
|
|
489
|
-
);
|
|
490
|
-
}
|
|
389
|
+
const failed = failedSrc === src;
|
|
491
390
|
return (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
391
|
+
<>
|
|
392
|
+
{failed && (
|
|
393
|
+
<span key="unavailable" className={`${UNAVAILABLE_IMAGE_CLASS} annotation-exclude`} title="Image could not be loaded" data-image-unavailable="">
|
|
394
|
+
{alt || 'Image'}
|
|
395
|
+
</span>
|
|
396
|
+
)}
|
|
397
|
+
<img
|
|
398
|
+
key="image"
|
|
399
|
+
src={src}
|
|
400
|
+
alt={failed ? '' : alt}
|
|
401
|
+
data-image-src={writtenSrc}
|
|
402
|
+
data-image-failed={failed ? '' : undefined}
|
|
403
|
+
hidden={failed || undefined}
|
|
404
|
+
aria-hidden={failed || undefined}
|
|
405
|
+
className={failed ? undefined : 'max-w-full rounded my-2 cursor-zoom-in'}
|
|
406
|
+
loading="lazy"
|
|
407
|
+
onError={() => setFailedSrc(src)}
|
|
408
|
+
onClick={(e) => {
|
|
409
|
+
e.stopPropagation();
|
|
410
|
+
if (!failed) onImageClick?.(src, alt);
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
</>
|
|
503
414
|
);
|
|
504
415
|
};
|
|
505
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Whether a digit-only 6/8-digit `#123456` is an issue ref (when a ref link can
|
|
419
|
+
* be built) rather than a hex swatch. Off by default: every host renders such
|
|
420
|
+
* values as colours. The Plannotator Inbox turns it on through
|
|
421
|
+
* `configurePlannotatorUI({ digitHexIssueRefs: true })` (#1932).
|
|
422
|
+
*/
|
|
423
|
+
let digitHexIssueRefs = false;
|
|
424
|
+
|
|
425
|
+
export function setDigitHexIssueRefs(on: boolean): void {
|
|
426
|
+
digitHexIssueRefs = on;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function resetDigitHexIssueRefs(): void {
|
|
430
|
+
digitHexIssueRefs = false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function getDigitHexIssueRefs(): boolean {
|
|
434
|
+
return digitHexIssueRefs;
|
|
435
|
+
}
|
|
436
|
+
|
|
506
437
|
/**
|
|
507
438
|
* Scanner that walks a text string and emits React nodes for inline markdown:
|
|
508
439
|
* emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
|
|
@@ -543,6 +474,13 @@ export const InlineMarkdown: React.FC<{
|
|
|
543
474
|
const resolveLocalLink = useLocalLinkResolver();
|
|
544
475
|
const resolveImage = useImageResolver();
|
|
545
476
|
const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
|
|
477
|
+
// Cross-repo `owner/repo#123` refs: only for a caller that names its
|
|
478
|
+
// project's repo (it knows the forge); otherwise they stay plain text, never
|
|
479
|
+
// a guessed github.com link. With a code-file handler, an `owner/repo` that
|
|
480
|
+
// is a code path not known to be missing stays the file (`src/app.js#12`).
|
|
481
|
+
const crossRepoProject = githubRepo ? { host: repoHost } : null;
|
|
482
|
+
const crossRepoAccept = (ref: CrossRepoRef): boolean =>
|
|
483
|
+
!(onOpenCodeFile && isCodeFilePathStrict(ref.path) && gateCodePath(ref.path, validation ?? null).render !== 'plain');
|
|
546
484
|
const parts: React.ReactNode[] = [];
|
|
547
485
|
let remaining = text;
|
|
548
486
|
let key = 0;
|
|
@@ -650,6 +588,32 @@ export const InlineMarkdown: React.FC<{
|
|
|
650
588
|
}
|
|
651
589
|
}
|
|
652
590
|
|
|
591
|
+
// Cross-repo ref: owner/repo#123, shown as written, linked on the
|
|
592
|
+
// project's forge host. Read before file-path detection, so
|
|
593
|
+
// `vercel/next.js#1` is a ref, not a path (unless the viewer knows that
|
|
594
|
+
// file, see crossRepoAccept); `docs/guide.md#2` stays a path.
|
|
595
|
+
const crossRepoRef = crossRepoProject ? matchCrossRepoRef(remaining, previousChar, crossRepoProject) : null;
|
|
596
|
+
if (crossRepoRef && crossRepoAccept(crossRepoRef)) {
|
|
597
|
+
parts.push(
|
|
598
|
+
crossRepoRef.href ? (
|
|
599
|
+
<a
|
|
600
|
+
key={key++}
|
|
601
|
+
href={crossRepoRef.href}
|
|
602
|
+
target="_blank"
|
|
603
|
+
rel="noopener noreferrer"
|
|
604
|
+
className="text-primary font-medium hover:underline"
|
|
605
|
+
>
|
|
606
|
+
{crossRepoRef.label}
|
|
607
|
+
</a>
|
|
608
|
+
) : (
|
|
609
|
+
crossRepoRef.label
|
|
610
|
+
),
|
|
611
|
+
);
|
|
612
|
+
remaining = remaining.slice(crossRepoRef.label.length);
|
|
613
|
+
previousChar = crossRepoRef.label[crossRepoRef.label.length - 1];
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
653
617
|
// Autolinks: <https://url> or <email@domain.com>
|
|
654
618
|
match = remaining.match(/^<(https?:\/\/[^>]+)>/);
|
|
655
619
|
if (match) {
|
|
@@ -846,9 +810,19 @@ export const InlineMarkdown: React.FC<{
|
|
|
846
810
|
}
|
|
847
811
|
|
|
848
812
|
// Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123.
|
|
813
|
+
// 6/8-digit forms with no letter (#186379) are ambiguous. By default they
|
|
814
|
+
// stay swatches (greys like #333333 are common in docs; six-digit issues are
|
|
815
|
+
// rare). A host that opts in with `configurePlannotatorUI({ digitHexIssueRefs:
|
|
816
|
+
// true })` (the Plannotator Inbox, #1932) gets an issue ref instead, but only
|
|
817
|
+
// when a ref link can actually be built (`refLinks`: a repo path on a known
|
|
818
|
+
// forge) and the ref branch below would take it: a word boundary and no
|
|
819
|
+
// leading zero (issue numbers never start with 0, so #000000 stays a colour).
|
|
849
820
|
match = remaining.match(
|
|
850
821
|
/^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/,
|
|
851
822
|
);
|
|
823
|
+
if (match && digitHexIssueRefs && refLinks && /^#[1-9]\d*$/.test(match[1]) && !/\w/.test(previousChar)) {
|
|
824
|
+
match = null;
|
|
825
|
+
}
|
|
852
826
|
if (match) {
|
|
853
827
|
const hex = match[1];
|
|
854
828
|
parts.push(
|
|
@@ -872,8 +846,8 @@ export const InlineMarkdown: React.FC<{
|
|
|
872
846
|
}
|
|
873
847
|
|
|
874
848
|
// Issue / PR reference: #123 — only at word boundary, digits only.
|
|
875
|
-
// Hex-swatch branch above already consumed #fff / #123abc etc
|
|
876
|
-
// #\d+ here is
|
|
849
|
+
// Hex-swatch branch above already consumed #fff / #123abc etc. (and digit-only
|
|
850
|
+
// #123456 / #12345678 unless the host opted in), so a bare #\d+ here is an issue ref.
|
|
877
851
|
if (!/\w/.test(previousChar)) {
|
|
878
852
|
match = remaining.match(/^#(\d+)(?!\w)/);
|
|
879
853
|
if (match) {
|
|
@@ -1027,12 +1001,13 @@ export const InlineMarkdown: React.FC<{
|
|
|
1027
1001
|
resolveImage ? (
|
|
1028
1002
|
// With a host resolver, an image that fails to load becomes the
|
|
1029
1003
|
// same placeholder instead of a broken-image icon.
|
|
1030
|
-
<HostResolvedImage key={key++} src={imgSrc} alt={alt} onImageClick={onImageClick} />
|
|
1004
|
+
<HostResolvedImage key={key++} src={imgSrc} alt={alt} writtenSrc={src} onImageClick={onImageClick} />
|
|
1031
1005
|
) : (
|
|
1032
1006
|
<img
|
|
1033
1007
|
key={key++}
|
|
1034
1008
|
src={imgSrc}
|
|
1035
1009
|
alt={alt}
|
|
1010
|
+
data-image-src={src}
|
|
1036
1011
|
className="max-w-full rounded my-2 cursor-zoom-in"
|
|
1037
1012
|
loading="lazy"
|
|
1038
1013
|
onClick={(e) => {
|
|
@@ -1272,6 +1247,24 @@ export const InlineMarkdown: React.FC<{
|
|
|
1272
1247
|
// multi-char patterns like emoji shortcodes. Bare URLs are instead
|
|
1273
1248
|
// detected inline via emitPlainTextWithBareUrls() below.
|
|
1274
1249
|
const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/);
|
|
1250
|
+
// A bare URL that starts inside this plain-text chunk and runs past its
|
|
1251
|
+
// end would be cut there, and `_`, `~`, `#`, `@`, `$` and `!` all end a
|
|
1252
|
+
// chunk (`…/w/ws_01…` rendered a link to `…/w/ws`). Stop the chunk where
|
|
1253
|
+
// that URL starts instead, so the bare-URL branch above takes it whole.
|
|
1254
|
+
const chunkEnd = nextSpecial === -1 ? remaining.length : nextSpecial + 1;
|
|
1255
|
+
// Same for an owner/repo#123 ref: `#` (and `_` in a name) ends a chunk, so
|
|
1256
|
+
// stop before the ref and let the cross-repo branch above take it whole.
|
|
1257
|
+
// A ref inside a URL never splits it: the URL starts first and wins.
|
|
1258
|
+
const urlAt = bareUrlRunningPast(remaining, chunkEnd);
|
|
1259
|
+
const refAt = crossRepoProject ? findCrossRepoRef(remaining, urlAt === -1 ? chunkEnd : urlAt, crossRepoProject, crossRepoAccept) : -1;
|
|
1260
|
+
const splitAt = refAt !== -1 ? refAt : urlAt;
|
|
1261
|
+
if (splitAt !== -1) {
|
|
1262
|
+
const before = remaining.slice(0, splitAt);
|
|
1263
|
+
emitPlainTextWithBareUrls(before, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
|
|
1264
|
+
previousChar = before[before.length - 1] || previousChar;
|
|
1265
|
+
remaining = remaining.slice(splitAt);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1275
1268
|
const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1);
|
|
1276
1269
|
emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
|
|
1277
1270
|
previousChar = plainText[plainText.length - 1] || previousChar;
|