@plannotator/ui 0.46.1 → 0.49.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 +114 -3
- package/README.md +41 -2
- package/components/AISettingsTab.tsx +6 -0
- package/components/AgentsTab.tsx +11 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/MarkdownEditor.tsx +9 -0
- package/components/ModelSourceHint.tsx +49 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +169 -6
- package/components/ai/AIProviderBar.tsx +11 -3
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/config/settings.ts +17 -0
- package/hooks/useAgentSettings.ts +8 -1
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useCodeAnnotationDraft.ts +24 -7
- package/hooks/useModelCatalogs.ts +28 -11
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +4 -4
- package/styles.css +1 -1
- package/types.ts +3 -0
- package/utils/aiProvider.ts +4 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/decisionSpec.ts +36 -2
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
|
@@ -72,6 +72,8 @@ interface UseCodeAnnotationDraftOptions {
|
|
|
72
72
|
autoViewSuppressed?: Set<string>;
|
|
73
73
|
isApiMode: boolean;
|
|
74
74
|
submitted: boolean;
|
|
75
|
+
/** Hosts with independent review-progress storage omit viewed state from drafts. */
|
|
76
|
+
persistViewedFiles?: boolean;
|
|
75
77
|
/** Receives the unsent items found on a target the session switched onto
|
|
76
78
|
* in place (#1590), already filtered to ids the session neither holds nor
|
|
77
79
|
* deleted. The host adds them to its state; autosave then saves the merge.
|
|
@@ -125,12 +127,15 @@ export function useCodeAnnotationDraft({
|
|
|
125
127
|
submitted,
|
|
126
128
|
onDraftTargetMerge,
|
|
127
129
|
targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
|
|
130
|
+
persistViewedFiles = true,
|
|
128
131
|
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
129
132
|
const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
|
|
130
133
|
const draftDataRef = useRef<DraftData | null>(null);
|
|
131
134
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
132
135
|
const hasMountedRef = useRef(false);
|
|
133
136
|
const draftGenerationRef = useRef(0);
|
|
137
|
+
const persistViewedRef = useRef(persistViewedFiles);
|
|
138
|
+
persistViewedRef.current = persistViewedFiles;
|
|
134
139
|
// True once the user has actually had annotations this session. Used to decide
|
|
135
140
|
// whether an empty state is a real "cleared everything" edit (persist it) vs a
|
|
136
141
|
// fresh/unengaged session (leave the server alone). Keyed on annotations only —
|
|
@@ -186,6 +191,9 @@ export function useCodeAnnotationDraft({
|
|
|
186
191
|
return data as DraftData | null;
|
|
187
192
|
})
|
|
188
193
|
.then((data: DraftData | null) => {
|
|
194
|
+
// Keep even a viewed-only draft while independent progress support is
|
|
195
|
+
// unresolved; a failed/unsupported load may need to offer it later.
|
|
196
|
+
draftDataRef.current = data;
|
|
189
197
|
const generation = readDraftGeneration(data?.draftGeneration);
|
|
190
198
|
if (generation !== null) {
|
|
191
199
|
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
|
|
@@ -193,7 +201,7 @@ export function useCodeAnnotationDraft({
|
|
|
193
201
|
const annotationCount = (Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0)
|
|
194
202
|
+ (Array.isArray(data?.descriptionAnnotations) ? data.descriptionAnnotations.length : 0)
|
|
195
203
|
+ (Array.isArray(data?.commentAnnotations) ? data.commentAnnotations.length : 0);
|
|
196
|
-
const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
|
|
204
|
+
const viewedCount = persistViewedRef.current && Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
|
|
197
205
|
if (annotationCount > 0 || viewedCount > 0) {
|
|
198
206
|
draftDataRef.current = data;
|
|
199
207
|
setDraftBanner({
|
|
@@ -209,6 +217,15 @@ export function useCodeAnnotationDraft({
|
|
|
209
217
|
});
|
|
210
218
|
}, [isApiMode]);
|
|
211
219
|
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const data = draftDataRef.current;
|
|
222
|
+
if (!data) return;
|
|
223
|
+
const count = (data.codeAnnotations?.length ?? 0)
|
|
224
|
+
+ (data.descriptionAnnotations?.length ?? 0) + (data.commentAnnotations?.length ?? 0);
|
|
225
|
+
const viewedCount = persistViewedFiles ? data.viewedFiles?.length ?? 0 : 0;
|
|
226
|
+
setDraftBanner(count || viewedCount ? { count, viewedCount, timeAgo: formatTimeAgo(data.ts || 0) } : null);
|
|
227
|
+
}, [persistViewedFiles]);
|
|
228
|
+
|
|
212
229
|
// Debounced auto-save on annotation/viewed changes
|
|
213
230
|
useEffect(() => {
|
|
214
231
|
if (!isApiMode || submitted) return;
|
|
@@ -223,7 +240,7 @@ export function useCodeAnnotationDraft({
|
|
|
223
240
|
// via `allAnnotations` and have their own lifecycle, separate from the draft.
|
|
224
241
|
if (annotations.some((a) => !a.source) || descriptionAnnotations.length > 0 || commentAnnotations.length > 0) hasHadAnnotationsRef.current = true;
|
|
225
242
|
|
|
226
|
-
const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && viewedFiles.size === 0;
|
|
243
|
+
const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && (!persistViewedFiles || viewedFiles.size === 0);
|
|
227
244
|
// Leave the server alone for an empty state until the user has actually had
|
|
228
245
|
// annotations this session. This preserves an unrestored draft sitting on disk
|
|
229
246
|
// at mount (the draft-recovery banner can still offer it).
|
|
@@ -259,8 +276,8 @@ export function useCodeAnnotationDraft({
|
|
|
259
276
|
codeAnnotations: annotations,
|
|
260
277
|
descriptionAnnotations,
|
|
261
278
|
commentAnnotations,
|
|
262
|
-
viewedFiles: [...viewedFiles],
|
|
263
|
-
...(autoViewSuppressed && autoViewSuppressed.size > 0
|
|
279
|
+
...(persistViewedFiles ? { viewedFiles: [...viewedFiles] } : {}),
|
|
280
|
+
...(persistViewedFiles && autoViewSuppressed && autoViewSuppressed.size > 0
|
|
264
281
|
? { autoViewSuppressed: [...autoViewSuppressed] }
|
|
265
282
|
: {}),
|
|
266
283
|
draftGeneration,
|
|
@@ -273,7 +290,7 @@ export function useCodeAnnotationDraft({
|
|
|
273
290
|
return () => {
|
|
274
291
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
275
292
|
};
|
|
276
|
-
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge]);
|
|
293
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles]);
|
|
277
294
|
|
|
278
295
|
const restoreDraft = useCallback(() => {
|
|
279
296
|
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
@@ -286,9 +303,9 @@ export function useCodeAnnotationDraft({
|
|
|
286
303
|
annotations: data?.codeAnnotations ?? [],
|
|
287
304
|
descriptionAnnotations: data?.descriptionAnnotations ?? [],
|
|
288
305
|
commentAnnotations: data?.commentAnnotations ?? [],
|
|
289
|
-
viewedFiles: data?.viewedFiles ?? [],
|
|
290
|
-
autoViewSuppressed: data?.autoViewSuppressed ?? [],
|
|
291
306
|
patchChanged: data?.patchChanged === true,
|
|
307
|
+
viewedFiles: persistViewedRef.current ? data?.viewedFiles ?? [] : [],
|
|
308
|
+
autoViewSuppressed: persistViewedRef.current ? data?.autoViewSuppressed ?? [] : [],
|
|
292
309
|
};
|
|
293
310
|
}, []);
|
|
294
311
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CLAUDE_FALLBACK_MODELS,
|
|
4
4
|
CODEX_FALLBACK_MODELS,
|
|
5
5
|
type CatalogModel,
|
|
6
|
+
type ModelsSource,
|
|
6
7
|
} from '@plannotator/core/model-catalog';
|
|
7
8
|
import type { AgentCapabilities } from '../types';
|
|
8
9
|
|
|
@@ -26,8 +27,14 @@ export type CatalogEngine = 'claude' | 'codex';
|
|
|
26
27
|
export interface ModelCatalog {
|
|
27
28
|
models: CatalogModel[];
|
|
28
29
|
settled: boolean;
|
|
30
|
+
/** Where the server says the list came from; absent when it did not say. */
|
|
31
|
+
modelsSource?: ModelsSource;
|
|
32
|
+
/** The installed CLI's version, when the server reports it. */
|
|
33
|
+
toolVersion?: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
type LoadedCatalog = Pick<ModelCatalog, 'models' | 'modelsSource' | 'toolVersion'>;
|
|
37
|
+
|
|
31
38
|
export interface ModelCatalogs extends Record<CatalogEngine, ModelCatalog> {
|
|
32
39
|
/** Fetch one engine's catalog (no-op for other engines or when not installed). */
|
|
33
40
|
load: (engine: string | null | undefined) => void;
|
|
@@ -47,19 +54,19 @@ const isCatalogEngine = (engine: unknown): engine is CatalogEngine => engine ===
|
|
|
47
54
|
|
|
48
55
|
// One request per engine per page: every launcher surface shares the answer.
|
|
49
56
|
// A failed request is forgotten so the next load retries it.
|
|
50
|
-
const loads = new Map<CatalogEngine, Promise<
|
|
57
|
+
const loads = new Map<CatalogEngine, Promise<LoadedCatalog>>();
|
|
51
58
|
|
|
52
|
-
|
|
59
|
+
function loadCatalogEntry(engine: CatalogEngine): Promise<LoadedCatalog> {
|
|
53
60
|
let load = loads.get(engine);
|
|
54
61
|
if (!load) {
|
|
55
62
|
const id = AI_PROVIDER_ID[engine];
|
|
56
|
-
const failed = () => {
|
|
63
|
+
const failed = (): LoadedCatalog => {
|
|
57
64
|
loads.delete(engine);
|
|
58
|
-
return FALLBACK_MODELS[engine];
|
|
65
|
+
return { models: FALLBACK_MODELS[engine] };
|
|
59
66
|
};
|
|
60
67
|
load = fetch(`/api/ai/capabilities?activate=${encodeURIComponent(id)}`)
|
|
61
68
|
.then((res) => (res.ok ? res.json() : null))
|
|
62
|
-
.then((data) => {
|
|
69
|
+
.then((data): LoadedCatalog => {
|
|
63
70
|
const provider = data?.providers?.find((p: { id?: string; name?: string }) => p.id === id || p.name === id);
|
|
64
71
|
const models = provider?.models;
|
|
65
72
|
if (!Array.isArray(models) || models.length === 0) return failed();
|
|
@@ -67,7 +74,13 @@ export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]>
|
|
|
67
74
|
// failed; show it, but forget it so the next load retries (the
|
|
68
75
|
// server's own cooldown bounds how often that spawns the CLI).
|
|
69
76
|
if (provider.modelsSource === 'fallback') loads.delete(engine);
|
|
70
|
-
return
|
|
77
|
+
return {
|
|
78
|
+
models: models as CatalogModel[],
|
|
79
|
+
...(provider.modelsSource === 'fallback' || provider.modelsSource === 'discovered'
|
|
80
|
+
? { modelsSource: provider.modelsSource as ModelsSource }
|
|
81
|
+
: {}),
|
|
82
|
+
...(typeof provider.toolVersion === 'string' && provider.toolVersion ? { toolVersion: provider.toolVersion } : {}),
|
|
83
|
+
};
|
|
71
84
|
})
|
|
72
85
|
.catch(failed);
|
|
73
86
|
loads.set(engine, load);
|
|
@@ -75,6 +88,10 @@ export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]>
|
|
|
75
88
|
return load;
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]> {
|
|
92
|
+
return loadCatalogEntry(engine).then((entry) => entry.models);
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
/** Test seam: forget cached catalogs. */
|
|
79
96
|
export function __resetModelCatalogsForTests(): void {
|
|
80
97
|
loads.clear();
|
|
@@ -85,7 +102,7 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
|
|
|
85
102
|
capabilities?.providers.some((p) => p.id === id && p.available) ?? false;
|
|
86
103
|
const claudeOn = available('claude');
|
|
87
104
|
const codexOn = available('codex');
|
|
88
|
-
const [loaded, setLoaded] = useState<Partial<Record<CatalogEngine,
|
|
105
|
+
const [loaded, setLoaded] = useState<Partial<Record<CatalogEngine, LoadedCatalog>>>({});
|
|
89
106
|
const mounted = useRef(true);
|
|
90
107
|
useEffect(() => {
|
|
91
108
|
mounted.current = true;
|
|
@@ -98,8 +115,8 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
|
|
|
98
115
|
(engine: string | null | undefined) => {
|
|
99
116
|
if (!isCatalogEngine(engine)) return;
|
|
100
117
|
if (!(engine === 'claude' ? claudeOn : codexOn)) return;
|
|
101
|
-
void
|
|
102
|
-
if (mounted.current) setLoaded((prev) => (prev[engine] ===
|
|
118
|
+
void loadCatalogEntry(engine).then((entry) => {
|
|
119
|
+
if (mounted.current) setLoaded((prev) => (prev[engine] === entry ? prev : { ...prev, [engine]: entry }));
|
|
103
120
|
});
|
|
104
121
|
},
|
|
105
122
|
[claudeOn, codexOn],
|
|
@@ -107,8 +124,8 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
|
|
|
107
124
|
|
|
108
125
|
return useMemo(
|
|
109
126
|
() => ({
|
|
110
|
-
claude: {
|
|
111
|
-
codex: {
|
|
127
|
+
claude: loaded.claude ? { ...loaded.claude, settled: true } : { models: FALLBACK_MODELS.claude, settled: false },
|
|
128
|
+
codex: loaded.codex ? { ...loaded.codex, settled: true } : { models: FALLBACK_MODELS.codex, settled: false },
|
|
112
129
|
load,
|
|
113
130
|
}),
|
|
114
131
|
[loaded, load],
|
package/hooks/usePinpoint.ts
CHANGED
|
@@ -137,6 +137,10 @@ export function usePinpoint({
|
|
|
137
137
|
|
|
138
138
|
const handleClick = (e: MouseEvent) => {
|
|
139
139
|
const target = e.target as HTMLElement;
|
|
140
|
+
// A `:::question` card's form controls are the answer UI, never a pin
|
|
141
|
+
// target: a click on a radio, a checkbox or a text box answers.
|
|
142
|
+
const inQuestion = !!target.closest?.('fieldset.question-block');
|
|
143
|
+
if (inQuestion && target.closest('input, textarea, select')) return;
|
|
140
144
|
const resolved = resolveSemanticTargetAtPoint(
|
|
141
145
|
buildSemanticTargetGraph(container),
|
|
142
146
|
target,
|
|
@@ -144,6 +148,12 @@ export function usePinpoint({
|
|
|
144
148
|
);
|
|
145
149
|
if (!resolved) return;
|
|
146
150
|
|
|
151
|
+
// Pinning a choice's text must not also pick the choice: the row is a
|
|
152
|
+
// <label>, which would forward this click to its radio or checkbox.
|
|
153
|
+
if (inQuestion && target.closest('[data-question-option]')) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
// Prevent link navigation in pinpoint mode
|
|
148
158
|
const link = (target.closest('a') as HTMLAnchorElement | null);
|
|
149
159
|
if (link && container.contains(link)) {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { hasBlockingOverlay } from './useVimDocumentFocus';
|
|
3
|
+
import { isDocumentScrollViewport } from './useScrollViewport';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scroll keys with no browser target (#1647).
|
|
7
|
+
*
|
|
8
|
+
* The desktop editor scrolls inside an element (`<main>`), not the window. On
|
|
9
|
+
* load nothing is focused and the user has not clicked anywhere, so the
|
|
10
|
+
* browser's keyboard-scroll starting point is the root scroller, which cannot
|
|
11
|
+
* scroll: ArrowDown, PageDown and Space do nothing until the reader clicks into
|
|
12
|
+
* the document. The same dead state returns after the node the user last
|
|
13
|
+
* clicked is removed (a dismissed first-run dialog) or when that click landed
|
|
14
|
+
* on chrome with nothing scrollable above it (the header).
|
|
15
|
+
*
|
|
16
|
+
* This hook fills exactly that gap and nothing else. It never moves focus, so
|
|
17
|
+
* dialogs, popovers, screen readers and `:focus-visible` are untouched. It
|
|
18
|
+
* only acts when all of these hold:
|
|
19
|
+
* - focus is neutral (body), so no control, input, editor or iframe owns
|
|
20
|
+
* the key;
|
|
21
|
+
* - no earlier listener consumed the key (`defaultPrevented`);
|
|
22
|
+
* - no modifier except Shift+Space (page up), and not mid-composition;
|
|
23
|
+
* - no dialog, modal overlay, popover, menu or listbox is open;
|
|
24
|
+
* - the browser has no scroll target of its own: the last pointerdown is
|
|
25
|
+
* absent, detached, or has no vertically scrollable ancestor. When it has
|
|
26
|
+
* one (a click in the document, the annotations panel, the sidebar), the
|
|
27
|
+
* native key scroll already works and is left alone.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const LINE_STEP_PX = 40;
|
|
31
|
+
/**
|
|
32
|
+
* Page step as Chromium and WebKit compute it: the larger of 87.5% of the
|
|
33
|
+
* viewport (kMinFractionToStepWhenPaging) and the viewport minus a 40px
|
|
34
|
+
* overlap (kMaxOverlapBetweenPages). On a normal window the overlap rule wins,
|
|
35
|
+
* so a routed PageDown matches the native one taken after a click.
|
|
36
|
+
*/
|
|
37
|
+
const PAGE_FRACTION = 0.875;
|
|
38
|
+
const PAGE_OVERLAP_PX = 40;
|
|
39
|
+
|
|
40
|
+
const OPEN_POPUP_SELECTOR = '[role="menu"], [role="listbox"]';
|
|
41
|
+
|
|
42
|
+
export type ScrollKeyAction =
|
|
43
|
+
| { readonly kind: 'line'; readonly direction: 1 | -1 }
|
|
44
|
+
| { readonly kind: 'page'; readonly direction: 1 | -1 }
|
|
45
|
+
| { readonly kind: 'edge'; readonly edge: 'start' | 'end' };
|
|
46
|
+
|
|
47
|
+
/** The keydown fields the mapping reads (pure, so it runs without a DOM). */
|
|
48
|
+
export type ScrollKeyInput = Pick<
|
|
49
|
+
KeyboardEvent,
|
|
50
|
+
'key' | 'isComposing' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
/** Map a keydown to the scroll it asks for, or null when it is not a plain scroll key. */
|
|
54
|
+
export function scrollKeyAction(event: ScrollKeyInput): ScrollKeyAction | null {
|
|
55
|
+
if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return null;
|
|
56
|
+
const isSpace = event.key === ' ' || event.key === 'Spacebar';
|
|
57
|
+
if (isSpace) return { kind: 'page', direction: event.shiftKey ? -1 : 1 };
|
|
58
|
+
if (event.shiftKey) return null;
|
|
59
|
+
switch (event.key) {
|
|
60
|
+
case 'ArrowDown': return { kind: 'line', direction: 1 };
|
|
61
|
+
case 'ArrowUp': return { kind: 'line', direction: -1 };
|
|
62
|
+
case 'PageDown': return { kind: 'page', direction: 1 };
|
|
63
|
+
case 'PageUp': return { kind: 'page', direction: -1 };
|
|
64
|
+
case 'Home': return { kind: 'edge', edge: 'start' };
|
|
65
|
+
case 'End': return { kind: 'edge', edge: 'end' };
|
|
66
|
+
default: return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canScrollVertically(element: Element): boolean {
|
|
71
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
72
|
+
if (element.scrollHeight <= element.clientHeight) return false;
|
|
73
|
+
const overflowY = getComputedStyle(element).overflowY;
|
|
74
|
+
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the browser already has a keyboard-scroll target: the last clicked
|
|
79
|
+
* node is still in the page and sits inside a vertically scrollable element.
|
|
80
|
+
*/
|
|
81
|
+
export function browserHasScrollTarget(lastPointerTarget: Node | null): boolean {
|
|
82
|
+
if (!lastPointerTarget || !lastPointerTarget.isConnected) return false;
|
|
83
|
+
const root = lastPointerTarget.ownerDocument?.documentElement ?? null;
|
|
84
|
+
let node: Node | null = lastPointerTarget;
|
|
85
|
+
while (node && node !== root) {
|
|
86
|
+
if (node instanceof Element && canScrollVertically(node)) return true;
|
|
87
|
+
const parent: Node | null = node.parentNode;
|
|
88
|
+
node = parent instanceof ShadowRoot ? parent.host : parent;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function focusIsNeutral(event: KeyboardEvent): boolean {
|
|
94
|
+
const doc = document;
|
|
95
|
+
const active = doc.activeElement;
|
|
96
|
+
if (active !== null && active !== doc.body && active !== doc.documentElement) return false;
|
|
97
|
+
const origin = event.composedPath?.()[0] ?? event.target;
|
|
98
|
+
return origin === doc.body || origin === doc.documentElement || origin === doc || origin === window;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function prefersReducedMotion(): boolean {
|
|
102
|
+
return typeof window.matchMedia === 'function'
|
|
103
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function applyScroll(viewport: HTMLElement, action: ScrollKeyAction): void {
|
|
107
|
+
if (action.kind === 'line') {
|
|
108
|
+
viewport.scrollBy({ top: action.direction * LINE_STEP_PX, behavior: 'auto' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
|
|
112
|
+
if (action.kind === 'edge') {
|
|
113
|
+
viewport.scrollTo({ top: action.edge === 'start' ? 0 : viewport.scrollHeight, behavior });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const height = viewport.clientHeight;
|
|
117
|
+
const page = Math.max(1, Math.round(Math.max(height * PAGE_FRACTION, height - PAGE_OVERLAP_PX)));
|
|
118
|
+
viewport.scrollBy({ top: action.direction * page, behavior });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface UseScrollKeyRoutingOptions {
|
|
122
|
+
/** The element that scrolls the document. Page-scrolling viewports are ignored. */
|
|
123
|
+
readonly viewport: HTMLElement | null;
|
|
124
|
+
/** False where another owner has the keys (vim, iframe surfaces). */
|
|
125
|
+
readonly enabled: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Route unowned scroll keys to the document viewport. See the module comment. */
|
|
129
|
+
export function useScrollKeyRouting({ viewport, enabled }: UseScrollKeyRoutingOptions): void {
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!enabled || !viewport || isDocumentScrollViewport(viewport)) return;
|
|
132
|
+
|
|
133
|
+
let lastPointerTarget: Node | null = null;
|
|
134
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
135
|
+
const origin = event.composedPath?.()[0] ?? event.target;
|
|
136
|
+
lastPointerTarget = origin instanceof Node ? origin : null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
140
|
+
if (event.defaultPrevented) return;
|
|
141
|
+
const action = scrollKeyAction(event);
|
|
142
|
+
if (!action) return;
|
|
143
|
+
if (!viewport.isConnected || !focusIsNeutral(event)) return;
|
|
144
|
+
if (hasBlockingOverlay() || document.querySelector(OPEN_POPUP_SELECTOR)) return;
|
|
145
|
+
if (browserHasScrollTarget(lastPointerTarget)) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
applyScroll(viewport, action);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
window.addEventListener('pointerdown', handlePointerDown, true);
|
|
151
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
152
|
+
return () => {
|
|
153
|
+
window.removeEventListener('pointerdown', handlePointerDown, true);
|
|
154
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
155
|
+
};
|
|
156
|
+
}, [enabled, viewport]);
|
|
157
|
+
}
|
package/hooks/useUndoHistory.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface UndoHistoryApi<TAction> {
|
|
|
13
13
|
readonly canUndo: boolean;
|
|
14
14
|
readonly canRedo: boolean;
|
|
15
15
|
record: (action: TAction) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Coalesce into the newest undo entry: when it is still `expected` (the
|
|
18
|
+
* same object), replace it with `next`, or drop it when `next` is null.
|
|
19
|
+
* Returns false, changing nothing, when anything was recorded, undone or
|
|
20
|
+
* cleared since. Used to fold a typing burst into one entry.
|
|
21
|
+
*/
|
|
22
|
+
replaceLast: (expected: TAction, next: TAction | null) => boolean;
|
|
16
23
|
undo: () => boolean;
|
|
17
24
|
redo: () => boolean;
|
|
18
25
|
clear: () => void;
|
|
@@ -57,6 +64,16 @@ export function useUndoHistory<TAction>({
|
|
|
57
64
|
const history = historyRef.current;
|
|
58
65
|
history.state = recordUndoAction(history.state, action, optionsRef.current.capacity);
|
|
59
66
|
},
|
|
67
|
+
replaceLast(expected, next) {
|
|
68
|
+
const history = historyRef.current;
|
|
69
|
+
const { past, future } = history.state;
|
|
70
|
+
if (past.length === 0 || past[past.length - 1] !== expected || future.length > 0) return false;
|
|
71
|
+
history.state = {
|
|
72
|
+
past: next === null ? past.slice(0, -1) : [...past.slice(0, -1), next],
|
|
73
|
+
future,
|
|
74
|
+
};
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
60
77
|
undo() {
|
|
61
78
|
const history = historyRef.current;
|
|
62
79
|
const step = takeUndoAction(history.state);
|
|
@@ -38,7 +38,8 @@ function pageFocusIsNeutral(): boolean {
|
|
|
38
38
|
return document.activeElement === document.body || document.activeElement === null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** True while a dialog, modal overlay, or dismissable popover owns the page. */
|
|
42
|
+
export function hasBlockingOverlay(): boolean {
|
|
42
43
|
return document.querySelector(BLOCKING_OVERLAY_SELECTOR) !== null;
|
|
43
44
|
}
|
|
44
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"@lezer/common": "^1.5.2",
|
|
76
76
|
"@lezer/highlight": "^1.2.3",
|
|
77
77
|
"@pierre/diffs": "1.3.6",
|
|
78
|
-
"@plannotator/atomic-editor": "^0.
|
|
79
|
-
"@plannotator/core": "0.25.
|
|
80
|
-
"@plannotator/markdown-editor": "^0.
|
|
78
|
+
"@plannotator/atomic-editor": "^0.9.0",
|
|
79
|
+
"@plannotator/core": "0.25.9",
|
|
80
|
+
"@plannotator/markdown-editor": "^0.5.0",
|
|
81
81
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
82
82
|
"@tanstack/react-table": "^8.21.3",
|
|
83
83
|
"@viz-js/viz": "3.30.0",
|