@plannotator/ui 0.52.1 → 0.53.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 +231 -27
- package/README.md +29 -4
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- 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/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 +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +189 -48
- 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/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- 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/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- 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/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
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import React, { useId, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
canonicalQuestionAnswer,
|
|
4
4
|
emptyQuestionAnswer,
|
|
5
5
|
isQuestionAnswerEmpty,
|
|
6
|
+
isRecommendedQuestionAnswer,
|
|
6
7
|
questionStatus,
|
|
7
8
|
recommendedQuestionAnswer,
|
|
8
9
|
type IndexedQuestion,
|
|
9
10
|
type ParsedQuestion,
|
|
10
11
|
type QuestionAnswer,
|
|
12
|
+
type QuestionStatus,
|
|
11
13
|
} from '@plannotator/core/question-block';
|
|
12
14
|
import { InlineMarkdown } from '../InlineMarkdown';
|
|
13
15
|
import { QuestionContext } from './QuestionContext';
|
|
16
|
+
import { HostTitle } from '../HostTitle';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* A `:::question` block rendered as an answer card (the approved "Card"
|
|
@@ -56,14 +59,25 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
|
|
|
56
59
|
|
|
57
60
|
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
|
58
61
|
|
|
62
|
+
/** The box every pill in the eyebrow shares (the status tags, the decision
|
|
63
|
+
* tags, the toggle and the split tag): one height, one line-height, and a
|
|
64
|
+
* 1px border on all of them (transparent unless the tone draws it), so the
|
|
65
|
+
* dashed "Open" tag is no taller than the rest. The pill group itself is a
|
|
66
|
+
* flex row centred on its axis, so no pill rides its own baseline (the split
|
|
67
|
+
* tag's baseline came from its icon-only switch, which lifted it).
|
|
68
|
+
* `align-[1px]` does nothing inside that flex row; it stays because it is
|
|
69
|
+
* the only use of the class, and dropping it would change the stylesheet
|
|
70
|
+
* the guides.show viewer pins (a deploy for no visible change). */
|
|
71
|
+
const PILL = 'annotation-exclude select-none inline-flex h-[18px] items-center gap-1 whitespace-nowrap rounded border px-1.5 align-[1px] text-[10.5px] font-semibold leading-4 tracking-[0.03em]';
|
|
72
|
+
|
|
59
73
|
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
|
|
60
74
|
<span
|
|
61
75
|
className={cx(
|
|
62
|
-
|
|
63
|
-
tone === 'rec' && 'bg-primary/15 text-primary',
|
|
64
|
-
tone === 'ok' && 'bg-success/15 text-success',
|
|
65
|
-
tone === 'skip' && 'bg-muted text-muted-foreground',
|
|
66
|
-
tone === 'open' && 'border
|
|
76
|
+
PILL,
|
|
77
|
+
tone === 'rec' && 'border-transparent bg-primary/15 text-primary',
|
|
78
|
+
tone === 'ok' && 'border-transparent bg-success/15 text-success',
|
|
79
|
+
tone === 'skip' && 'border-transparent bg-muted text-muted-foreground',
|
|
80
|
+
tone === 'open' && 'border-dashed border-border text-muted-foreground',
|
|
67
81
|
className,
|
|
68
82
|
)}
|
|
69
83
|
style={style}
|
|
@@ -85,15 +99,18 @@ const DiamondGlyph = () => (
|
|
|
85
99
|
</svg>
|
|
86
100
|
);
|
|
87
101
|
|
|
88
|
-
/** Draws a decision tag in its "off" state: the same words
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
102
|
+
/** Draws a decision tag in its "off" state: the same words with a dotted
|
|
103
|
+
* outline instead of the tinted fill, faded. The theme's muted-foreground
|
|
104
|
+
* alone is exactly the "Open" tag's color beside it, so the off tag read as
|
|
105
|
+
* barely dimmer than an on one. Text, icon and outline (all `currentColor`)
|
|
106
|
+
* are muted-foreground mixed 55% with transparent: the look of
|
|
107
|
+
* `opacity: 0.55` on the tag, without `opacity`, which would also fade the
|
|
108
|
+
* keyboard focus ring (a `ring-primary` box-shadow on the tag's buttons)
|
|
109
|
+
* that must stay at full strength. Inline so it adds no utility classes to
|
|
110
|
+
* the shared stylesheet and wins over the tag's `text-primary`. */
|
|
94
111
|
const DECISION_OFF_STYLE: React.CSSProperties = {
|
|
95
112
|
background: 'transparent',
|
|
96
|
-
color: 'var(--muted-foreground)',
|
|
113
|
+
color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)',
|
|
97
114
|
outline: '1px dotted currentColor',
|
|
98
115
|
outlineOffset: '-1px',
|
|
99
116
|
};
|
|
@@ -164,6 +181,15 @@ export interface QuestionBlockProps {
|
|
|
164
181
|
* Skipped) so the host can draw its own; the decision tags stay. Default
|
|
165
182
|
* `'card'`. */
|
|
166
183
|
statusTag?: 'card' | 'none';
|
|
184
|
+
/** The host's own words for the card's status tag, per question (ui seam;
|
|
185
|
+
* the Plannotator Inbox says "Not sent" for a pick it has saved but not
|
|
186
|
+
* sent, where the card would say "Answered"). Return undefined for the
|
|
187
|
+
* card's own tag. Absent: today's tags. Ignored with `statusTag: 'none'`. */
|
|
188
|
+
statusLabel?: (question: IndexedQuestion, status: QuestionStatus) => QuestionStatusLabel | undefined;
|
|
189
|
+
/** Host content drawn right under the prompt (a host note such as "Replaces
|
|
190
|
+
* <an earlier question>"), given the question. Return null for none.
|
|
191
|
+
* Absent: nothing is drawn there (today's card). */
|
|
192
|
+
renderBelowPrompt?: (question: IndexedQuestion) => React.ReactNode;
|
|
167
193
|
onOpenLinkedDoc?: (path: string) => void;
|
|
168
194
|
onOpenCodeFile?: (path: string) => void;
|
|
169
195
|
imageBaseDir?: string;
|
|
@@ -173,6 +199,12 @@ export interface QuestionBlockProps {
|
|
|
173
199
|
onNavigateAnchor?: (hash: string) => void;
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
/** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */
|
|
203
|
+
export interface QuestionStatusLabel {
|
|
204
|
+
label: string;
|
|
205
|
+
tone?: 'ok' | 'open' | 'skip';
|
|
206
|
+
}
|
|
207
|
+
|
|
176
208
|
export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
177
209
|
blockId,
|
|
178
210
|
indexed,
|
|
@@ -187,6 +219,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
187
219
|
onToggleDecisionRecording,
|
|
188
220
|
onOpenDecision,
|
|
189
221
|
statusTag = 'card',
|
|
222
|
+
statusLabel,
|
|
223
|
+
renderBelowPrompt,
|
|
190
224
|
onOpenLinkedDoc,
|
|
191
225
|
onOpenCodeFile,
|
|
192
226
|
imageBaseDir,
|
|
@@ -256,6 +290,10 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
256
290
|
const noteOpen = notePinned ?? !!answer?.note?.trim();
|
|
257
291
|
const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
|
|
258
292
|
const noteRef = useRef<HTMLTextAreaElement>(null);
|
|
293
|
+
const cardRef = useRef<HTMLFieldSetElement>(null);
|
|
294
|
+
// Accept recommended (and Use) go away once clicked: focus moves to what
|
|
295
|
+
// the click filled in, never to the page.
|
|
296
|
+
const focusAfterAcceptRef = useRef(false);
|
|
259
297
|
const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
|
|
260
298
|
|
|
261
299
|
const inline = (text: string) => (
|
|
@@ -274,11 +312,30 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
274
312
|
// A note alone does not change a settled choice: the [x] stays drawn (and
|
|
275
313
|
// the status stays Settled) until the reviewer picks, fills Other or skips.
|
|
276
314
|
const status = questionStatus(question, answer);
|
|
315
|
+
const hostStatus = statusTag === 'none' ? undefined : statusLabel?.(indexed, status);
|
|
277
316
|
const answered = status === 'answered';
|
|
278
317
|
const skipped = status === 'skipped';
|
|
279
318
|
const settled = status === 'settled';
|
|
280
319
|
const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label);
|
|
281
320
|
const hasRecommendation = question.choices.some((c) => c.recommended) || !!question.suggestedText;
|
|
321
|
+
// "Accept recommended" stays offered on a pick-any question whose
|
|
322
|
+
// recommendation names choices until the answer is exactly those choices:
|
|
323
|
+
// ticking one box must not take away the one click that ticks the rest. A
|
|
324
|
+
// pick-one or text question answered any way, a question with a settled
|
|
325
|
+
// choice, and a pick-any question recommending only a suggestion (whose
|
|
326
|
+
// Suggested line goes once the card is answered, so Accept would clear the
|
|
327
|
+
// ticks for something no longer shown) offer it no more.
|
|
328
|
+
const offerAccept = !readOnly && hasRecommendation && !settled && (
|
|
329
|
+
!answered
|
|
330
|
+
|| (question.kind === 'multi'
|
|
331
|
+
&& question.choices.some((c) => c.recommended)
|
|
332
|
+
&& !question.choices.some((c) => c.settled)
|
|
333
|
+
&& !isRecommendedQuestionAnswer(
|
|
334
|
+
answer!,
|
|
335
|
+
question.choices.filter((c) => c.recommended).map((c) => c.label),
|
|
336
|
+
question.suggestedText,
|
|
337
|
+
))
|
|
338
|
+
);
|
|
282
339
|
const selected = answered || skipped ? answer!.selected : settledLabels;
|
|
283
340
|
const base = answer ?? emptyQuestionAnswer(indexed);
|
|
284
341
|
|
|
@@ -332,8 +389,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
332
389
|
|
|
333
390
|
const acceptRecommended = () => {
|
|
334
391
|
const next = recommendedQuestionAnswer(indexed, base);
|
|
335
|
-
if (next)
|
|
392
|
+
if (!next) return;
|
|
393
|
+
focusAfterAcceptRef.current = true;
|
|
394
|
+
commit(next);
|
|
336
395
|
};
|
|
396
|
+
// Runs after the render that drew the accepted answer (and once more at
|
|
397
|
+
// most, should the host not redraw it): the first checked choice, else the
|
|
398
|
+
// field holding the suggestion.
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (!focusAfterAcceptRef.current) return;
|
|
401
|
+
focusAfterAcceptRef.current = false;
|
|
402
|
+
const card = cardRef.current;
|
|
403
|
+
if (!card) return;
|
|
404
|
+
// Focus still on something in the card (the button was not removed): leave it.
|
|
405
|
+
const active = document.activeElement;
|
|
406
|
+
if (active && active !== document.body && card.contains(active)) return;
|
|
407
|
+
const target = card.querySelector<HTMLElement>('input:checked:not(:disabled)')
|
|
408
|
+
?? card.querySelector<HTMLElement>('textarea:not(:disabled), input[type="text"]:not(:disabled)');
|
|
409
|
+
target?.focus({ preventScroll: true });
|
|
410
|
+
});
|
|
337
411
|
|
|
338
412
|
const toggleNote = () => {
|
|
339
413
|
const opening = !noteOpen;
|
|
@@ -394,12 +468,33 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
394
468
|
? (recordingOn ? 'on' : 'off')
|
|
395
469
|
: undefined;
|
|
396
470
|
|
|
397
|
-
const describedBy = [question.context ? contextId : null, question.
|
|
471
|
+
const describedBy = [question.context ? contextId : null, question.suggestedText && !answered ? suggestId : null]
|
|
398
472
|
.filter(Boolean)
|
|
399
473
|
.join(' ') || undefined;
|
|
400
474
|
|
|
475
|
+
// The suggestion the agent wrote that is not one of the choices (a choice
|
|
476
|
+
// question's `Recommended:` naming none of them; a text question's whole
|
|
477
|
+
// suggestion): "Accept recommended" fills it in, so the card shows it.
|
|
478
|
+
const suggestedLine = question.suggestedText && !answered && (
|
|
479
|
+
<div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
|
|
480
|
+
<span className="min-w-0 flex-1">
|
|
481
|
+
<Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
|
|
482
|
+
</span>
|
|
483
|
+
{!readOnly && (
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
onClick={acceptRecommended}
|
|
487
|
+
className="shrink-0 rounded-md bg-primary/15 px-2.5 py-[3px] text-xs font-medium text-primary hover:bg-primary/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
488
|
+
>
|
|
489
|
+
Use
|
|
490
|
+
</button>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
|
|
401
495
|
return (
|
|
402
496
|
<fieldset
|
|
497
|
+
ref={cardRef}
|
|
403
498
|
className={cx(
|
|
404
499
|
'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
|
|
405
500
|
// Native radios/checkboxes follow the theme's mode. ThemeProvider marks
|
|
@@ -422,7 +517,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
422
517
|
>
|
|
423
518
|
<div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
|
|
424
519
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
|
|
425
|
-
<span className="ml-auto">
|
|
520
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
426
521
|
{question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
|
|
427
522
|
{decisionCapable && (decisionOpenable ? (
|
|
428
523
|
// Two targets in one tag: the diamond switches recording, the
|
|
@@ -435,27 +530,26 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
435
530
|
// meet the spacing exception: nothing else to click within 12 px
|
|
436
531
|
// of their centre line.
|
|
437
532
|
<><span
|
|
438
|
-
className="annotation-exclude select-none inline-flex items-stretch whitespace-nowrap rounded
|
|
533
|
+
className="annotation-exclude select-none inline-flex h-[18px] items-stretch whitespace-nowrap rounded border border-transparent text-[10.5px] font-semibold leading-4 tracking-[0.03em] bg-primary/15 text-primary"
|
|
439
534
|
style={recordingOn ? undefined : DECISION_OFF_STYLE}
|
|
440
535
|
data-pinpoint-ignore=""
|
|
441
536
|
data-question-decision-tag=""
|
|
442
537
|
>{decisionToggleable ? (
|
|
443
|
-
<button
|
|
538
|
+
<HostTitle title="Record as a decision"><button
|
|
444
539
|
type="button"
|
|
445
540
|
aria-pressed={recordingOn}
|
|
446
541
|
aria-label="Record as a decision"
|
|
447
|
-
title="Record as a decision"
|
|
448
542
|
onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
|
|
449
543
|
className="relative inline-flex cursor-pointer items-center rounded pl-1.5 pr-0.5 before:absolute before:-inset-1.5 before:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
450
544
|
data-question-decision-toggle=""
|
|
451
|
-
><DiamondGlyph /></button>
|
|
545
|
+
><DiamondGlyph /></button></HostTitle>
|
|
452
546
|
) : (
|
|
453
547
|
<span className="inline-flex items-center pl-1.5 pr-0.5"><DiamondGlyph /></span>
|
|
454
548
|
)}<button
|
|
455
549
|
type="button"
|
|
456
550
|
aria-haspopup="dialog"
|
|
457
551
|
onClick={(event) => onOpenDecision!(question.key, event.currentTarget)}
|
|
458
|
-
className="relative cursor-pointer rounded
|
|
552
|
+
className="relative cursor-pointer rounded pl-0.5 pr-1.5 hover:underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
459
553
|
data-question-decision-open=""
|
|
460
554
|
>Records a decision</button></span>{' '}</>
|
|
461
555
|
) : decisionToggleable ? (
|
|
@@ -465,7 +559,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
465
559
|
type="button"
|
|
466
560
|
aria-pressed={recordingOn}
|
|
467
561
|
onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
|
|
468
|
-
className=
|
|
562
|
+
className={cx(PILL, 'cursor-pointer border-transparent bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary')}
|
|
469
563
|
style={recordingOn ? undefined : DECISION_OFF_STYLE}
|
|
470
564
|
data-pinpoint-ignore=""
|
|
471
565
|
data-question-decision-toggle=""
|
|
@@ -475,7 +569,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
475
569
|
) : (
|
|
476
570
|
<><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
477
571
|
))}
|
|
478
|
-
{statusTag !== 'none' &&
|
|
572
|
+
{statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
|
|
573
|
+
{statusTag !== 'none' && !hostStatus && (
|
|
479
574
|
<>
|
|
480
575
|
{status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
|
|
481
576
|
{status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
|
|
@@ -489,6 +584,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
489
584
|
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
490
585
|
{inline(question.prompt)}
|
|
491
586
|
</p>
|
|
587
|
+
{renderBelowPrompt?.(indexed)}
|
|
492
588
|
{(question.decision || (decisionCapable && recordingOn)) && (
|
|
493
589
|
<div
|
|
494
590
|
className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
|
|
@@ -530,22 +626,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
530
626
|
|
|
531
627
|
{question.kind === 'text' ? (
|
|
532
628
|
<>
|
|
533
|
-
{
|
|
534
|
-
<div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground">
|
|
535
|
-
<span className="min-w-0 flex-1">
|
|
536
|
-
<Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
|
|
537
|
-
</span>
|
|
538
|
-
{!readOnly && (
|
|
539
|
-
<button
|
|
540
|
-
type="button"
|
|
541
|
-
onClick={acceptRecommended}
|
|
542
|
-
className="shrink-0 rounded-md bg-primary/15 px-2.5 py-[3px] text-xs font-medium text-primary hover:bg-primary/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
543
|
-
>
|
|
544
|
-
Use
|
|
545
|
-
</button>
|
|
546
|
-
)}
|
|
547
|
-
</div>
|
|
548
|
-
)}
|
|
629
|
+
{suggestedLine}
|
|
549
630
|
<textarea
|
|
550
631
|
className={cx(
|
|
551
632
|
// annotation-exclude: typed text is not document text, so a
|
|
@@ -561,65 +642,71 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
561
642
|
/>
|
|
562
643
|
</>
|
|
563
644
|
) : (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
645
|
+
<>
|
|
646
|
+
{suggestedLine}
|
|
647
|
+
<div
|
|
648
|
+
className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
|
|
649
|
+
role={inputType === 'radio' ? 'radiogroup' : 'group'}
|
|
650
|
+
aria-labelledby={promptId}
|
|
651
|
+
>
|
|
652
|
+
{question.choices.map((choice) => {
|
|
653
|
+
const checked = selected.includes(choice.label);
|
|
654
|
+
return (
|
|
655
|
+
<label
|
|
656
|
+
key={choice.label}
|
|
657
|
+
className={cx(
|
|
658
|
+
'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
|
|
659
|
+
readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
|
|
660
|
+
checked && 'bg-primary/12 hover:bg-primary/12',
|
|
661
|
+
)}
|
|
662
|
+
data-question-option=""
|
|
663
|
+
onPointerDown={onRowPointerDown}
|
|
664
|
+
onClickCapture={(event) => onRowClick(event, choice.label)}
|
|
665
|
+
>
|
|
666
|
+
<input
|
|
667
|
+
type={inputType}
|
|
668
|
+
name={groupName}
|
|
669
|
+
checked={checked}
|
|
670
|
+
onChange={(e) => pick(choice.label, e.target.checked)}
|
|
671
|
+
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
672
|
+
disabled={readOnly}
|
|
673
|
+
/>
|
|
674
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
|
|
675
|
+
<span className="font-[550]">{inline(choice.label)}</span>
|
|
676
|
+
{choice.description && (
|
|
677
|
+
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
678
|
+
)}
|
|
679
|
+
{choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
|
|
680
|
+
</span>
|
|
681
|
+
</label>
|
|
682
|
+
);
|
|
683
|
+
})}
|
|
684
|
+
{/* A read-only card with no Other answer has nothing to show there (it drew a bare "+"). */}
|
|
685
|
+
{(!readOnly || otherOn) && (
|
|
686
|
+
<div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
|
|
687
|
+
<span
|
|
688
|
+
aria-hidden="true"
|
|
689
|
+
className={cx(
|
|
690
|
+
'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
|
|
691
|
+
question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
|
|
692
|
+
otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
|
|
693
|
+
)}
|
|
694
|
+
>
|
|
695
|
+
{otherOn ? <CheckGlyph /> : '+'}
|
|
696
|
+
</span>
|
|
583
697
|
<input
|
|
584
|
-
type=
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
onChange={(e) => pick(choice.label, e.target.checked)}
|
|
588
|
-
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
698
|
+
type="text"
|
|
699
|
+
value={other}
|
|
700
|
+
onChange={(e) => setOther(e.target.value)}
|
|
589
701
|
disabled={readOnly}
|
|
702
|
+
placeholder={readOnly ? '' : 'Other…'}
|
|
703
|
+
aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
|
|
704
|
+
className="annotation-exclude min-w-0 flex-1 border-0 border-b border-transparent bg-transparent py-[3px] text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring"
|
|
590
705
|
/>
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
{choice.description && (
|
|
594
|
-
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
595
|
-
)}
|
|
596
|
-
{choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
|
|
597
|
-
</span>
|
|
598
|
-
</label>
|
|
599
|
-
);
|
|
600
|
-
})}
|
|
601
|
-
<div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
|
|
602
|
-
<span
|
|
603
|
-
aria-hidden="true"
|
|
604
|
-
className={cx(
|
|
605
|
-
'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
|
|
606
|
-
question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
|
|
607
|
-
otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
|
|
608
|
-
)}
|
|
609
|
-
>
|
|
610
|
-
{otherOn ? <CheckGlyph /> : '+'}
|
|
611
|
-
</span>
|
|
612
|
-
<input
|
|
613
|
-
type="text"
|
|
614
|
-
value={other}
|
|
615
|
-
onChange={(e) => setOther(e.target.value)}
|
|
616
|
-
disabled={readOnly}
|
|
617
|
-
placeholder={readOnly ? '' : 'Other…'}
|
|
618
|
-
aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
|
|
619
|
-
className="annotation-exclude min-w-0 flex-1 border-0 border-b border-transparent bg-transparent py-[3px] text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring"
|
|
620
|
-
/>
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
621
708
|
</div>
|
|
622
|
-
|
|
709
|
+
</>
|
|
623
710
|
)}
|
|
624
711
|
|
|
625
712
|
{noteOpen && (!readOnly || !!answer?.note?.trim()) && (
|
|
@@ -674,7 +761,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
674
761
|
{/* Plannotator's own footer (no host actions, no save bar) keeps the
|
|
675
762
|
Accept button as the one right-aligned item. */}
|
|
676
763
|
{!hasHostFooter && !dirty ? (
|
|
677
|
-
|
|
764
|
+
offerAccept && (
|
|
678
765
|
<button
|
|
679
766
|
type="button"
|
|
680
767
|
onClick={acceptRecommended}
|
|
@@ -685,7 +772,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
685
772
|
)
|
|
686
773
|
) : (
|
|
687
774
|
<div className="ml-auto flex items-center gap-1">
|
|
688
|
-
{
|
|
775
|
+
{offerAccept && (
|
|
689
776
|
<button
|
|
690
777
|
type="button"
|
|
691
778
|
onClick={acceptRecommended}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import { diagramFamilyOf } from '../../utils/diagram-anchor';
|
|
@@ -43,6 +43,14 @@ export type DiagramAskAI = (
|
|
|
43
43
|
additionalTargets: readonly DiagramTarget[],
|
|
44
44
|
) => boolean | void | Promise<boolean | void>;
|
|
45
45
|
|
|
46
|
+
/** A part chosen for a comment the host composes (`onHostDraft`). */
|
|
47
|
+
export interface DiagramHostDraft {
|
|
48
|
+
/** The anchor a comment there carries (its `sourceLine` in document lines). */
|
|
49
|
+
readonly anchor: DiagramAnchor;
|
|
50
|
+
/** Drop the draft: the part's ring goes. A draft already replaced ignores it. */
|
|
51
|
+
readonly cancel: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export interface DiagramViewerProps {
|
|
47
55
|
readonly kind: DiagramKind;
|
|
48
56
|
/** The diagram text. With `onSave` this is the saved baseline the pane's
|
|
@@ -52,6 +60,11 @@ export interface DiagramViewerProps {
|
|
|
52
60
|
readonly comments: readonly DiagramComment[];
|
|
53
61
|
/** A comment composed on a part. Absent: clicks open nothing. */
|
|
54
62
|
readonly onCreateComment?: DiagramCreateComment;
|
|
63
|
+
/** Opt-in host capability: the composer handed to the host. A click on a
|
|
64
|
+
* part reports it here (null when the draft closes) and no composer is
|
|
65
|
+
* drawn; the host saves the comment and passes it back in `comments`.
|
|
66
|
+
* Absent: unchanged. */
|
|
67
|
+
readonly onHostDraft?: (draft: DiagramHostDraft | null) => void;
|
|
55
68
|
/** Ask AI from the composer. Absent: the composer offers no Ask AI. */
|
|
56
69
|
readonly onAskAI?: DiagramAskAI;
|
|
57
70
|
/** Save the pane's text. Absent: there is no Source pane. */
|
|
@@ -104,6 +117,7 @@ export function DiagramViewer({
|
|
|
104
117
|
theme,
|
|
105
118
|
comments,
|
|
106
119
|
onCreateComment,
|
|
120
|
+
onHostDraft,
|
|
107
121
|
onAskAI,
|
|
108
122
|
onSave,
|
|
109
123
|
readOnlySource = false,
|
|
@@ -142,7 +156,7 @@ export function DiagramViewer({
|
|
|
142
156
|
const [svgRoot, setSvgRoot] = useState<SVGSVGElement | null>(null);
|
|
143
157
|
const onSvgRoot = useCallback((root: SVGSVGElement | null) => setSvgRoot(root), []);
|
|
144
158
|
|
|
145
|
-
const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined;
|
|
159
|
+
const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined || onHostDraft !== undefined;
|
|
146
160
|
const commentsState = useDiagramComments({
|
|
147
161
|
finder,
|
|
148
162
|
svgRoot,
|
|
@@ -190,6 +204,29 @@ export function DiagramViewer({
|
|
|
190
204
|
if (!sourceOpen) cancelComposer();
|
|
191
205
|
}, [cancelComposer, sourceOpen]);
|
|
192
206
|
|
|
207
|
+
// The composer handed to the host: the draft's part goes out, and the
|
|
208
|
+
// host's cancel closes it only while it is still the open one.
|
|
209
|
+
const onHostDraftRef = useRef(onHostDraft);
|
|
210
|
+
onHostDraftRef.current = onHostDraft;
|
|
211
|
+
const openComposer = commentsState.composer;
|
|
212
|
+
const openComposerRef = useRef(openComposer);
|
|
213
|
+
openComposerRef.current = openComposer;
|
|
214
|
+
const hostComposes = onHostDraft !== undefined;
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const report = onHostDraftRef.current;
|
|
217
|
+
if (!report) return;
|
|
218
|
+
if (openComposer === null) {
|
|
219
|
+
report(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
report({
|
|
223
|
+
anchor: buildDiagramAnchorValue(openComposer.primary.target, openComposer.sourceLine),
|
|
224
|
+
cancel: () => {
|
|
225
|
+
if (openComposerRef.current === openComposer) cancelComposer();
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}, [cancelComposer, hostComposes, openComposer]);
|
|
229
|
+
|
|
193
230
|
// Ask AI asks about the part the draft is on; the host's handler decides
|
|
194
231
|
// whether the question was taken, and a taken question closes the draft
|
|
195
232
|
// exactly as the markdown composer closes.
|
|
@@ -215,7 +252,7 @@ export function DiagramViewer({
|
|
|
215
252
|
selectedCommentId={selectedCommentId}
|
|
216
253
|
onSelectComment={onSelectComment}
|
|
217
254
|
renderComposer={(anchorRect) =>
|
|
218
|
-
commentsState.composer === null ? null : (
|
|
255
|
+
commentsState.composer === null || hostComposes ? null : (
|
|
219
256
|
<DiagramComposer
|
|
220
257
|
key={`${commentsState.composer.primary.target.id ?? ''}:${commentsState.composer.primary.target.from ?? ''}:${commentsState.composer.primary.target.to ?? ''}`}
|
|
221
258
|
draft={commentsState.composer}
|
|
@@ -233,7 +270,7 @@ export function DiagramViewer({
|
|
|
233
270
|
}
|
|
234
271
|
/>
|
|
235
272
|
),
|
|
236
|
-
[askFromComposer, commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
|
|
273
|
+
[askFromComposer, commentingDisabledReason, commentsState, hostComposes, onSelectComment, selectedCommentId, sourceDirty],
|
|
237
274
|
);
|
|
238
275
|
|
|
239
276
|
const showFallback = render.svgNode === null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
|
|
7
7
|
* `diagram-projection`).
|
|
8
8
|
*/
|
|
9
|
-
export { DiagramViewer, type DiagramAskAI, type DiagramViewerProps } from './DiagramViewer';
|
|
9
|
+
export { DiagramViewer, type DiagramAskAI, type DiagramHostDraft, type DiagramViewerProps } from './DiagramViewer';
|
|
10
10
|
export { DiagramPopout } from './DiagramPopout';
|
|
11
11
|
export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
|
|
12
12
|
export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
|