@plannotator/ui 0.47.0 → 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 +79 -0
- package/README.md +33 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- 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 +3 -3
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/config/settings.ts +17 -0
- package/hooks/useAnnotationHighlighter.ts +4 -0
- 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 +2 -2
- package/styles.css +1 -1
- package/types.ts +3 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
|
@@ -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",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@lezer/highlight": "^1.2.3",
|
|
77
77
|
"@pierre/diffs": "1.3.6",
|
|
78
78
|
"@plannotator/atomic-editor": "^0.9.0",
|
|
79
|
-
"@plannotator/core": "0.25.
|
|
79
|
+
"@plannotator/core": "0.25.9",
|
|
80
80
|
"@plannotator/markdown-editor": "^0.5.0",
|
|
81
81
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
82
82
|
"@tanstack/react-table": "^8.21.3",
|