@plannotator/ui 0.54.0 → 0.56.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 +102 -6
- package/README.md +128 -3
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocBadges.tsx +47 -35
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/ImageZoomStage.tsx +32 -4
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +22 -7
- package/components/Viewer.tsx +51 -19
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +21 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/contents-rail.css +337 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/inbox-thread.css +4542 -0
- package/package.json +13 -4
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +105 -0
- package/utils/syntaxTheme.ts +1 -0
- package/utils/themeRegistry.ts +11 -0
package/components/Settings.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal
|
|
|
7
7
|
import type { Origin } from '@plannotator/core/agents';
|
|
8
8
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
9
9
|
import type { TokenHoverDelay } from '@plannotator/core/token-hover';
|
|
10
|
+
import { basenameFromPath } from '@plannotator/core/source-save';
|
|
10
11
|
import {
|
|
11
12
|
configStore,
|
|
12
13
|
useConfigValue,
|
|
@@ -2670,7 +2671,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
2670
2671
|
>
|
|
2671
2672
|
{detectedVaults.map((vault) => (
|
|
2672
2673
|
<option key={vault} value={vault}>
|
|
2673
|
-
{vault
|
|
2674
|
+
{basenameFromPath(vault)}
|
|
2674
2675
|
</option>
|
|
2675
2676
|
))}
|
|
2676
2677
|
<option value={CUSTOM_PATH_SENTINEL}>Custom path...</option>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useMemo, useCallback } from 'react';
|
|
2
|
+
import { basenameFromPath } from '@plannotator/core/source-save';
|
|
2
3
|
import type { Block, Annotation } from '../types';
|
|
3
4
|
import {
|
|
4
5
|
buildTocHierarchy,
|
|
@@ -22,6 +23,17 @@ interface TableOfContentsProps {
|
|
|
22
23
|
linkedDocFilepath?: string | null;
|
|
23
24
|
onLinkedDocBack?: () => void;
|
|
24
25
|
backLabel?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The element a click scrolls. Default: the `ScrollViewportProvider`
|
|
28
|
+
* context's viewport (plan review). A host that draws the list outside the
|
|
29
|
+
* document's scroll area (the Inbox's comments column) names it here.
|
|
30
|
+
*/
|
|
31
|
+
scrollViewport?: HTMLElement | null;
|
|
32
|
+
/**
|
|
33
|
+
* The deepest heading level listed. Default 3 (plan review's list); the
|
|
34
|
+
* contents rail's surface (`DocumentContents`) lists 1 to 4, as the rail does.
|
|
35
|
+
*/
|
|
36
|
+
maxLevel?: number;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
// The prototype's TOC is a FLAT list — heading depth is conveyed by indentation
|
|
@@ -43,7 +55,7 @@ function flattenToc(items: TocItem[]): TocItem[] {
|
|
|
43
55
|
// H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row
|
|
44
56
|
// is a soft neutral surface tint (not a loud primary fill).
|
|
45
57
|
function itemClasses(level: number, isActive: boolean): string {
|
|
46
|
-
const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
|
|
58
|
+
const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : level === 3 ? 'ml-6' : 'ml-9';
|
|
47
59
|
const tone = isActive
|
|
48
60
|
? 'bg-surface-1 text-foreground'
|
|
49
61
|
: level <= 1
|
|
@@ -62,6 +74,8 @@ export function TableOfContents({
|
|
|
62
74
|
linkedDocFilepath,
|
|
63
75
|
onLinkedDocBack,
|
|
64
76
|
backLabel,
|
|
77
|
+
scrollViewport: scrollViewportProp,
|
|
78
|
+
maxLevel = 3,
|
|
65
79
|
}: TableOfContentsProps) {
|
|
66
80
|
// Annotation count per section (kept — production feature).
|
|
67
81
|
const annotationCounts = useMemo(
|
|
@@ -69,15 +83,16 @@ export function TableOfContents({
|
|
|
69
83
|
[blocks, annotations]
|
|
70
84
|
);
|
|
71
85
|
|
|
72
|
-
// Build the hierarchy (filters to heading levels ≤
|
|
73
|
-
// then flatten to a plain list.
|
|
86
|
+
// Build the hierarchy (filters to heading levels ≤ maxLevel and attaches
|
|
87
|
+
// counts), then flatten to a plain list.
|
|
74
88
|
const tocItems = useMemo(
|
|
75
|
-
() => flattenToc(buildTocHierarchy(blocks, annotationCounts)),
|
|
76
|
-
[blocks, annotationCounts]
|
|
89
|
+
() => flattenToc(buildTocHierarchy(blocks, annotationCounts, maxLevel)),
|
|
90
|
+
[blocks, annotationCounts, maxLevel]
|
|
77
91
|
);
|
|
78
92
|
|
|
79
93
|
// The real scroll element is the OverlayScrollArea viewport, not <main>.
|
|
80
|
-
const
|
|
94
|
+
const contextViewport = useScrollViewport();
|
|
95
|
+
const scrollViewport = scrollViewportProp === undefined ? contextViewport : scrollViewportProp;
|
|
81
96
|
|
|
82
97
|
// Smooth scroll-to-heading, accounting for the sticky header.
|
|
83
98
|
const handleNavigate = useCallback(
|
|
@@ -132,7 +147,7 @@ export function TableOfContents({
|
|
|
132
147
|
)}
|
|
133
148
|
</div>
|
|
134
149
|
<p className="text-[11px] text-foreground/70 truncate mt-0.5" title={linkedDocFilepath}>
|
|
135
|
-
{linkedDocFilepath
|
|
150
|
+
{basenameFromPath(linkedDocFilepath)}
|
|
136
151
|
</p>
|
|
137
152
|
</div>
|
|
138
153
|
)}
|
package/components/Viewer.tsx
CHANGED
|
@@ -86,9 +86,10 @@ import { type QuickLabel } from '../utils/quickLabels';
|
|
|
86
86
|
import type { SelectionAction } from '../utils/selectionActions';
|
|
87
87
|
import type { MentionSource } from '../utils/mentions';
|
|
88
88
|
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
89
|
+
import { legacyBadgesMaxWidth } from './docBadgesWidth';
|
|
89
90
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
90
91
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
91
|
-
import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
92
|
+
import { rangeInsideExcluded, useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
92
93
|
import { useVimSelection } from '../hooks/useVimSelection';
|
|
93
94
|
import {
|
|
94
95
|
getScrollViewportIntersectionRoot,
|
|
@@ -320,6 +321,10 @@ export interface ViewerProps {
|
|
|
320
321
|
/** A small label over each picture tile's picture (see `QuestionBlock`
|
|
321
322
|
* `pictureBadge`); absent: none. */
|
|
322
323
|
questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
324
|
+
/** A picture tile's zoom, told its question's key and the choice's index (see
|
|
325
|
+
* `QuestionBlock` `onZoomChoice`). Absent: the zoom opens the picture
|
|
326
|
+
* through `onImageClick`, as before. */
|
|
327
|
+
onQuestionPictureZoom?: (key: string, index: number, url: string, alt: string) => void;
|
|
323
328
|
/** Makes a question's "Records a decision" tag a toggle button
|
|
324
329
|
* (`aria-pressed`); a click calls this with the question's key and the
|
|
325
330
|
* next state, which the host stores and returns from
|
|
@@ -399,12 +404,14 @@ export interface ViewerProps {
|
|
|
399
404
|
* Opt-in host capability, with `answerOnly`: the reader may comment on the
|
|
400
405
|
* text. A text selection opens the selection toolbar, comment-only
|
|
401
406
|
* (Comment and the one-click 👍; no Delete, no label picker), and its
|
|
402
|
-
* comment composer; `annotations` are drawn as highlights.
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
407
|
+
* comment composer; `annotations` are drawn as highlights. A rendered
|
|
408
|
+
* diagram takes comments on its parts the same way (`onAddAnnotation` gets
|
|
409
|
+
* a row with `diagramAnchor`). Question cards are left out of selection
|
|
410
|
+
* (their text is not commented on), and nothing else of authoring comes
|
|
411
|
+
* back (no pinpoint, code-block toolbar, Global comment or Copy, no Vim).
|
|
412
|
+
* For a host that shows a message whose words can be commented on (the
|
|
413
|
+
* Plannotator Inbox's agent messages). Ignored without `answerOnly` and
|
|
414
|
+
* under `readOnly`. Absent → unchanged.
|
|
408
415
|
*/
|
|
409
416
|
textComments?: boolean;
|
|
410
417
|
/**
|
|
@@ -643,6 +650,7 @@ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode
|
|
|
643
650
|
{/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
|
|
644
651
|
<div
|
|
645
652
|
data-print-hide
|
|
653
|
+
data-sticky-actions-spacer
|
|
646
654
|
aria-hidden="true"
|
|
647
655
|
className="float-right"
|
|
648
656
|
style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
|
|
@@ -833,6 +841,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
833
841
|
questionContractLines = false,
|
|
834
842
|
questionPictureChoices = true,
|
|
835
843
|
questionPictureBadge,
|
|
844
|
+
onQuestionPictureZoom,
|
|
836
845
|
onToggleQuestionDecisionRecording,
|
|
837
846
|
onOpenQuestionDecision,
|
|
838
847
|
questionDecisionLocked,
|
|
@@ -915,27 +924,41 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
915
924
|
// card's top padding. One row fits; a second row (diff badge) or mobile
|
|
916
925
|
// wrapping outgrows the padding and lands on the document's first heading.
|
|
917
926
|
// Measure the cluster and insert exactly the clearance it needs (0 when it fits).
|
|
927
|
+
// The cluster also stops where the action cluster starts on the same row, so
|
|
928
|
+
// a long file name truncates at the buttons instead of running under them;
|
|
929
|
+
// with no buttons on its row (the phone layout) it stops inside the card.
|
|
918
930
|
const docBadgesRef = useRef<HTMLDivElement | null>(null);
|
|
919
931
|
const [badgeClearance, setBadgeClearance] = useState(0);
|
|
932
|
+
const [badgeMaxWidth, setBadgeMaxWidth] = useState<number | null>(null);
|
|
920
933
|
useEffect(() => {
|
|
921
934
|
if (hasViewerAnnotationHeader) {
|
|
922
935
|
setBadgeClearance(0);
|
|
936
|
+
setBadgeMaxWidth(null);
|
|
923
937
|
return;
|
|
924
938
|
}
|
|
925
939
|
const el = docBadgesRef.current;
|
|
926
940
|
const article = containerRef.current;
|
|
927
|
-
if (!el || !article) { setBadgeClearance(0); return; }
|
|
941
|
+
if (!el || !article) { setBadgeClearance(0); setBadgeMaxWidth(null); return; }
|
|
942
|
+
// The static float that holds the actions' place: the sticky lane's spacer
|
|
943
|
+
// (the lane itself moves while stuck), or the plain float without one.
|
|
944
|
+
const actions = article.querySelector<HTMLElement>('[data-sticky-actions-spacer]')
|
|
945
|
+
?? article.querySelector<HTMLElement>('[data-sticky-actions]');
|
|
928
946
|
const measure = () => {
|
|
929
947
|
const pad = parseFloat(getComputedStyle(article).paddingTop) || 0;
|
|
930
948
|
const overflow = el.offsetTop + el.offsetHeight - pad;
|
|
931
949
|
setBadgeClearance(overflow > 1 ? overflow + 4 : 0);
|
|
950
|
+
setBadgeMaxWidth(legacyBadgesMaxWidth(el.getBoundingClientRect(), actions?.getBoundingClientRect() ?? null, article.getBoundingClientRect()));
|
|
932
951
|
};
|
|
933
952
|
measure();
|
|
934
953
|
const ro = new ResizeObserver(measure);
|
|
935
954
|
ro.observe(el);
|
|
955
|
+
// The card too: when it narrows (the sidebar opens, the layout settles after
|
|
956
|
+
// first paint) the actions float moves without changing size.
|
|
957
|
+
ro.observe(article);
|
|
958
|
+
if (actions) ro.observe(actions);
|
|
936
959
|
window.addEventListener('resize', measure);
|
|
937
960
|
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
|
938
|
-
}, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
961
|
+
}, [hasViewerAnnotationHeader, stickyActions, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
939
962
|
|
|
940
963
|
// Per-doc heading slug map with dedup — computed once per blocks array so
|
|
941
964
|
// anchor ids stay stable across re-renders and duplicate heading texts get
|
|
@@ -1329,8 +1352,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1329
1352
|
const container = containerRef.current;
|
|
1330
1353
|
if (!container || !scrollViewport) return false;
|
|
1331
1354
|
|
|
1332
|
-
const target =
|
|
1333
|
-
if (!target
|
|
1355
|
+
const target = container.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`);
|
|
1356
|
+
if (!target) return false;
|
|
1334
1357
|
|
|
1335
1358
|
const documentHeader = stickyActions
|
|
1336
1359
|
? container.querySelector<HTMLElement>('[data-viewer-document-header]')
|
|
@@ -1375,6 +1398,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1375
1398
|
const handleCopy = (e: ClipboardEvent) => {
|
|
1376
1399
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
1377
1400
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
1401
|
+
// A live selection where nothing is annotated (an Inbox action item's text) is
|
|
1402
|
+
// the person's own: the browser copies it, never an older toolbar's text.
|
|
1403
|
+
const live = window.getSelection();
|
|
1404
|
+
if (live && !live.isCollapsed && live.rangeCount > 0 && containerRef.current && rangeInsideExcluded(containerRef.current, live.getRangeAt(0))) return;
|
|
1378
1405
|
|
|
1379
1406
|
if (toolbarState?.selectionText) {
|
|
1380
1407
|
e.preventDefault();
|
|
@@ -1726,9 +1753,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1726
1753
|
...(vimModeActive ? { outline: 'none' } : {}),
|
|
1727
1754
|
} as React.CSSProperties}
|
|
1728
1755
|
>
|
|
1729
|
-
{/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
|
|
1730
1756
|
{!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
1731
|
-
<div
|
|
1757
|
+
<div
|
|
1758
|
+
ref={docBadgesRef}
|
|
1759
|
+
data-print-hide
|
|
1760
|
+
className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}
|
|
1761
|
+
style={badgeMaxWidth !== null ? { maxWidth: badgeMaxWidth } : undefined}
|
|
1762
|
+
>
|
|
1732
1763
|
<DocBadges
|
|
1733
1764
|
layout="column"
|
|
1734
1765
|
repoInfo={repoInfo}
|
|
@@ -1835,10 +1866,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1835
1866
|
annotations={annotations}
|
|
1836
1867
|
selectedAnnotationId={selectedAnnotationId}
|
|
1837
1868
|
onSelectAnnotation={onSelectAnnotation}
|
|
1838
|
-
onAddAnnotation={
|
|
1839
|
-
readOnly={
|
|
1869
|
+
onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
|
|
1870
|
+
readOnly={!selectionCommentsOn}
|
|
1840
1871
|
onRestoreReport={onRestoreReport}
|
|
1841
|
-
onAskAI={
|
|
1872
|
+
onAskAI={selectionCommentsOn ? onAskAI : undefined}
|
|
1842
1873
|
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1843
1874
|
/>
|
|
1844
1875
|
</Suspense>
|
|
@@ -1851,10 +1882,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1851
1882
|
annotations={annotations}
|
|
1852
1883
|
selectedAnnotationId={selectedAnnotationId}
|
|
1853
1884
|
onSelectAnnotation={onSelectAnnotation}
|
|
1854
|
-
onAddAnnotation={
|
|
1855
|
-
readOnly={
|
|
1885
|
+
onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
|
|
1886
|
+
readOnly={!selectionCommentsOn}
|
|
1856
1887
|
onRestoreReport={onRestoreReport}
|
|
1857
|
-
onAskAI={
|
|
1888
|
+
onAskAI={selectionCommentsOn ? onAskAI : undefined}
|
|
1858
1889
|
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1859
1890
|
/>
|
|
1860
1891
|
</Suspense>
|
|
@@ -1980,6 +2011,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1980
2011
|
questionContractLines={questionContractLines}
|
|
1981
2012
|
questionPictureChoices={questionPictureChoices}
|
|
1982
2013
|
questionPictureBadge={questionPictureBadge}
|
|
2014
|
+
onQuestionPictureZoom={onQuestionPictureZoom}
|
|
1983
2015
|
/>
|
|
1984
2016
|
);
|
|
1985
2017
|
// `textComments`: a question card's words are not commented on
|
|
@@ -271,6 +271,11 @@ export interface QuestionBlockProps {
|
|
|
271
271
|
* answer, never read by the parser. Default absent, so every card draws as
|
|
272
272
|
* before (the Plannotator Inbox's onboarding tour labels its pictures). */
|
|
273
273
|
pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
274
|
+
/** The zoom button on a picture tile, told which question and which choice
|
|
275
|
+
* (its index in the question's choices) as well as the picture: a host can
|
|
276
|
+
* open a gallery of that question's choices (the Plannotator Inbox's option
|
|
277
|
+
* sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
|
|
278
|
+
onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
/** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
|
|
@@ -321,6 +326,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
321
326
|
onNavigateAnchor,
|
|
322
327
|
pictureChoices = true,
|
|
323
328
|
pictureBadge,
|
|
329
|
+
onZoomChoice,
|
|
324
330
|
}) => {
|
|
325
331
|
const { question, number } = indexed;
|
|
326
332
|
const resolveImage = useImageResolver();
|
|
@@ -880,7 +886,11 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
880
886
|
name={groupName}
|
|
881
887
|
readOnly={readOnly}
|
|
882
888
|
onPick={pick}
|
|
883
|
-
onZoom={
|
|
889
|
+
onZoom={
|
|
890
|
+
onZoomChoice
|
|
891
|
+
? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
|
|
892
|
+
: onImageClick
|
|
893
|
+
}
|
|
884
894
|
onPictureError={markPictureFailed}
|
|
885
895
|
heldTitle={pictureHeldTitle}
|
|
886
896
|
renderText={inline}
|
|
@@ -1121,8 +1131,8 @@ export interface QuestionPictureTilesProps {
|
|
|
1121
1131
|
readOnly?: boolean;
|
|
1122
1132
|
/** The tile's radio or checkbox changed (the picture and the label pick through it). */
|
|
1123
1133
|
onPick: (label: string, checked: boolean) => void;
|
|
1124
|
-
/** The separate zoom button (absent: no zoom button). Never picks. */
|
|
1125
|
-
onZoom?: (url: string, alt: string) => void;
|
|
1134
|
+
/** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
|
|
1135
|
+
onZoom?: (url: string, alt: string, index: number) => void;
|
|
1126
1136
|
onPictureError?: (url: string) => void;
|
|
1127
1137
|
/** The host's reason a picture is held (its image resolver's `unavailable` title). */
|
|
1128
1138
|
heldTitle?: (src: string) => string | null;
|
|
@@ -1256,7 +1266,7 @@ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
|
|
|
1256
1266
|
{shown && onZoom && (
|
|
1257
1267
|
<button
|
|
1258
1268
|
type="button"
|
|
1259
|
-
onClick={() => onZoom(shown, alt)}
|
|
1269
|
+
onClick={() => onZoom(shown, alt, index)}
|
|
1260
1270
|
aria-label={`Zoom: ${alt}`}
|
|
1261
1271
|
className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
1262
1272
|
data-pinpoint-ignore=""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Send, Square } from 'lucide-react';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { isImeCompositionKey } from '../../utils/commentSubmitKey';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import { Textarea } from '../ui/textarea';
|
|
8
|
+
import { CHAT_SEND_BUTTON, CHAT_STOP_BUTTON, FOCUS_RING } from './chatRecipes';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The text box's own box: border, radius, padding and type size. Spelled once
|
|
12
|
+
* so a host's layer painted over the field (`overlay`) can share its metrics.
|
|
13
|
+
* The scrollbar gutter is reserved, so a field that scrolls wraps where the
|
|
14
|
+
* layer does. 16 px below md keeps iOS Safari from zooming on focus.
|
|
15
|
+
*/
|
|
16
|
+
export const CHAT_COMPOSER_FIELD_BOX =
|
|
17
|
+
'rounded-md border px-3 py-2 text-base md:text-sm [scrollbar-gutter:stable]';
|
|
18
|
+
|
|
19
|
+
export interface ChatComposerProps {
|
|
20
|
+
/** The field's text. The composer is controlled: it holds none itself, so
|
|
21
|
+
* `onChange` must set this in the same tick or typing and IME input are lost. */
|
|
22
|
+
readonly value: string;
|
|
23
|
+
readonly onChange: (value: string) => void;
|
|
24
|
+
/** Enter in the field, Mod+Enter included, and the Send button. It runs even
|
|
25
|
+
* while `busy` or with an empty box, so a host decides what those mean. */
|
|
26
|
+
readonly onSend: () => void;
|
|
27
|
+
/** False: plain Enter is a new line and Mod+Enter or the button sends. For a
|
|
28
|
+
* touch keyboard, which has no Shift to make a new line with. */
|
|
29
|
+
readonly sendOnEnter?: boolean;
|
|
30
|
+
/** Stop, while `busy`. */
|
|
31
|
+
readonly onStop?: () => void;
|
|
32
|
+
/** A reply is running: the Send button becomes Stop. */
|
|
33
|
+
readonly busy?: boolean;
|
|
34
|
+
readonly disabled?: boolean;
|
|
35
|
+
/** The field's accessible name. */
|
|
36
|
+
readonly label?: string;
|
|
37
|
+
readonly placeholder?: string;
|
|
38
|
+
readonly sendLabel?: string;
|
|
39
|
+
readonly stopLabel?: string;
|
|
40
|
+
/** The host's handle on the field. */
|
|
41
|
+
readonly composerRef?: React.RefObject<HTMLTextAreaElement | null>;
|
|
42
|
+
/** Above the box (errors, queued messages). */
|
|
43
|
+
readonly notices?: React.ReactNode;
|
|
44
|
+
/** The quiet context line under the box. */
|
|
45
|
+
readonly below?: React.ReactNode;
|
|
46
|
+
/** The row with Send, left of it. */
|
|
47
|
+
readonly toolbar?: React.ReactNode;
|
|
48
|
+
/** In the box's positioned wrapper, before the field (a host's own menu). */
|
|
49
|
+
readonly assist?: React.ReactNode;
|
|
50
|
+
/** Painted over the field, in the field's own box. */
|
|
51
|
+
readonly overlay?: React.ReactNode;
|
|
52
|
+
/** Extra field classes. */
|
|
53
|
+
readonly fieldClassName?: string;
|
|
54
|
+
/** The id of the element describing what the message carries. */
|
|
55
|
+
readonly describedBy?: string;
|
|
56
|
+
/** With a host's menu in `assist`: whether it is open and which row is active. */
|
|
57
|
+
readonly expanded?: boolean;
|
|
58
|
+
readonly activeDescendant?: string;
|
|
59
|
+
/** Files pasted into the field. Absent: the paste is left to the browser. */
|
|
60
|
+
readonly onFiles?: (files: readonly File[]) => void;
|
|
61
|
+
/** Pasted text, before the field takes it: true when the host took it. */
|
|
62
|
+
readonly onPasteText?: (text: string) => boolean;
|
|
63
|
+
/** The caret or the selection moved. */
|
|
64
|
+
readonly onCaret?: (field: HTMLTextAreaElement) => void;
|
|
65
|
+
/** An IME composition started (true) or ended (false). */
|
|
66
|
+
readonly onComposing?: (composing: boolean) => void;
|
|
67
|
+
/** Runs before the composer's own keys; `preventDefault` keeps Enter from sending. */
|
|
68
|
+
readonly onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The message box. It has no picker of its own; a host adds one through `assist`. */
|
|
72
|
+
export function ChatComposer({
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
onSend,
|
|
76
|
+
sendOnEnter = true,
|
|
77
|
+
onStop,
|
|
78
|
+
busy = false,
|
|
79
|
+
disabled,
|
|
80
|
+
label = 'Message',
|
|
81
|
+
placeholder,
|
|
82
|
+
sendLabel = 'Send',
|
|
83
|
+
stopLabel = 'Stop',
|
|
84
|
+
composerRef,
|
|
85
|
+
notices,
|
|
86
|
+
below,
|
|
87
|
+
toolbar,
|
|
88
|
+
assist,
|
|
89
|
+
overlay,
|
|
90
|
+
fieldClassName,
|
|
91
|
+
describedBy,
|
|
92
|
+
expanded,
|
|
93
|
+
activeDescendant,
|
|
94
|
+
onFiles,
|
|
95
|
+
onPasteText,
|
|
96
|
+
onCaret,
|
|
97
|
+
onComposing,
|
|
98
|
+
onKeyDown,
|
|
99
|
+
}: ChatComposerProps) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex max-h-[60%] shrink-0 flex-col gap-2 border-t border-border px-3 pb-2 pt-2.5">
|
|
102
|
+
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto empty:hidden">{notices}</div>
|
|
103
|
+
<div className="relative shrink-0">
|
|
104
|
+
{assist}
|
|
105
|
+
<Textarea
|
|
106
|
+
ref={composerRef}
|
|
107
|
+
rows={2}
|
|
108
|
+
value={value}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
placeholder={placeholder}
|
|
111
|
+
aria-label={label}
|
|
112
|
+
aria-autocomplete={expanded === undefined ? undefined : 'list'}
|
|
113
|
+
aria-expanded={expanded}
|
|
114
|
+
aria-activedescendant={activeDescendant}
|
|
115
|
+
aria-describedby={describedBy}
|
|
116
|
+
// The box grows with its text up to 128 px, then scrolls inside itself.
|
|
117
|
+
className={cn(
|
|
118
|
+
CHAT_COMPOSER_FIELD_BOX,
|
|
119
|
+
'field-sizing-content max-h-32 min-h-16 resize-none bg-card shadow-none',
|
|
120
|
+
FOCUS_RING,
|
|
121
|
+
fieldClassName,
|
|
122
|
+
)}
|
|
123
|
+
onChange={(event) => onChange(event.target.value)}
|
|
124
|
+
onSelect={(event) => onCaret?.(event.currentTarget)}
|
|
125
|
+
onClick={(event) => onCaret?.(event.currentTarget)}
|
|
126
|
+
onKeyUp={(event) => {
|
|
127
|
+
if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
|
|
128
|
+
onCaret?.(event.currentTarget);
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
onCompositionStart={() => onComposing?.(true)}
|
|
132
|
+
onCompositionEnd={() => onComposing?.(false)}
|
|
133
|
+
onPaste={(event) => {
|
|
134
|
+
const files = Array.from(event.clipboardData?.files ?? []);
|
|
135
|
+
if (files.length > 0 && onFiles !== undefined) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
onFiles(files);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
141
|
+
if (text !== '' && onPasteText?.(text) === true) event.preventDefault();
|
|
142
|
+
}}
|
|
143
|
+
onKeyDown={(event) => {
|
|
144
|
+
// Enter, in any chord, does not bubble past the React root, so a
|
|
145
|
+
// bubble listener on window or document (a Send elsewhere on the
|
|
146
|
+
// page) does not hear it. Capture listeners and native listeners on
|
|
147
|
+
// the box's ancestors still do, and keyup is not stopped.
|
|
148
|
+
const enter = event.key === 'Enter';
|
|
149
|
+
if (enter) event.stopPropagation();
|
|
150
|
+
// Safari's Enter that commits a composition has isComposing false
|
|
151
|
+
// and keyCode 229; this checks both.
|
|
152
|
+
if (isImeCompositionKey(event)) return;
|
|
153
|
+
onKeyDown?.(event);
|
|
154
|
+
if (!enter || event.defaultPrevented) return;
|
|
155
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
156
|
+
if (!mod && (event.shiftKey || !sendOnEnter)) return; // a new line
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
if (!event.shiftKey) onSend();
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
{overlay}
|
|
162
|
+
</div>
|
|
163
|
+
{below}
|
|
164
|
+
<div className="flex min-w-0 shrink-0 items-center gap-1" data-chat-composer-toolbar>
|
|
165
|
+
{toolbar}
|
|
166
|
+
<div className="ml-auto shrink-0">
|
|
167
|
+
{busy ? (
|
|
168
|
+
<Button
|
|
169
|
+
variant="ghost"
|
|
170
|
+
size="icon"
|
|
171
|
+
aria-label={stopLabel}
|
|
172
|
+
className={CHAT_STOP_BUTTON}
|
|
173
|
+
onClick={onStop}
|
|
174
|
+
>
|
|
175
|
+
<Square aria-hidden="true" className="size-3" />
|
|
176
|
+
</Button>
|
|
177
|
+
) : (
|
|
178
|
+
<Button
|
|
179
|
+
size="icon"
|
|
180
|
+
aria-label={sendLabel}
|
|
181
|
+
className={CHAT_SEND_BUTTON}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
onClick={onSend}
|
|
184
|
+
>
|
|
185
|
+
<Send aria-hidden="true" className="size-3.5" />
|
|
186
|
+
</Button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|