@plannotator/ui 0.47.0 → 0.50.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.
Files changed (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
@@ -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
+ }
@@ -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
- function hasBlockingOverlay(): boolean {
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,7 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.47.0",
3
+ "version": "0.50.0",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "exports": {
6
7
  "./components/*": "./components/*.tsx",
@@ -76,7 +77,7 @@
76
77
  "@lezer/highlight": "^1.2.3",
77
78
  "@pierre/diffs": "1.3.6",
78
79
  "@plannotator/atomic-editor": "^0.9.0",
79
- "@plannotator/core": "0.25.7",
80
+ "@plannotator/core": "0.25.10",
80
81
  "@plannotator/markdown-editor": "^0.5.0",
81
82
  "@plannotator/web-highlighter": "^0.8.1",
82
83
  "@tanstack/react-table": "^8.21.3",