@plannotator/ui 0.30.0 → 0.31.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/README.md +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +62 -32
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +136 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +208 -39
- package/components/html-viewer/bridge-script.ts +319 -65
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +146 -57
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { getScrollViewportIntersectionRoot } from './useScrollViewport';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Track which heading section is currently visible in the viewport
|
|
@@ -16,11 +17,12 @@ export function useActiveSection(
|
|
|
16
17
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
17
18
|
|
|
18
19
|
useEffect(() => {
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const contentContainer = containerRef.current ?? scrollElement;
|
|
21
|
+
const viewport = scrollElement ?? contentContainer;
|
|
22
|
+
if (!contentContainer || !viewport) return;
|
|
21
23
|
|
|
22
24
|
// Find all heading elements with data-block-id
|
|
23
|
-
const headings =
|
|
25
|
+
const headings = contentContainer.querySelectorAll('[data-block-type="heading"]');
|
|
24
26
|
if (headings.length === 0) return;
|
|
25
27
|
|
|
26
28
|
// Track which headings are currently intersecting
|
|
@@ -57,7 +59,7 @@ export function useActiveSection(
|
|
|
57
59
|
}
|
|
58
60
|
},
|
|
59
61
|
{
|
|
60
|
-
root:
|
|
62
|
+
root: getScrollViewportIntersectionRoot(viewport),
|
|
61
63
|
rootMargin: '-80px 0px -80% 0px', // Activate when heading is near top
|
|
62
64
|
threshold: [0, 0.1, 0.5, 1.0],
|
|
63
65
|
}
|
package/hooks/useAgentJobs.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type AgentLaunchParams = {
|
|
|
37
37
|
* schema-capable engine and starts a new, normal guide job rather than
|
|
38
38
|
* mutating the failed one in place. */
|
|
39
39
|
repairOf?: string;
|
|
40
|
+
/** Reviewer-supplied extra instructions (#1265), appended to the Guided
|
|
41
|
+
* Review organizer prompt. Guide launches only; other providers ignore it. */
|
|
42
|
+
instructions?: string;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
/** Does a job belong to the given review context? Jobs launched against a PR
|
|
@@ -27,6 +27,7 @@ export interface CommentPopoverState {
|
|
|
27
27
|
selectedText?: string;
|
|
28
28
|
initialText?: string;
|
|
29
29
|
source?: any;
|
|
30
|
+
draftKey: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface QuickLabelPickerState {
|
|
@@ -46,6 +47,21 @@ type MathAnnotationSource = {
|
|
|
46
47
|
const isMathAnnotationSource = (source: any): source is MathAnnotationSource =>
|
|
47
48
|
source?.kind === 'math';
|
|
48
49
|
|
|
50
|
+
function commentDraftTargetKey(source: any, selectedText: string): string {
|
|
51
|
+
if (isMathAnnotationSource(source)) {
|
|
52
|
+
return `math:${source.blockId}:${source.text}`;
|
|
53
|
+
}
|
|
54
|
+
const start = source?.startMeta;
|
|
55
|
+
const end = source?.endMeta;
|
|
56
|
+
const startKey = start
|
|
57
|
+
? `${start.parentTagName}:${start.parentIndex}:${start.textOffset}`
|
|
58
|
+
: 'unknown';
|
|
59
|
+
const endKey = end
|
|
60
|
+
? `${end.parentTagName}:${end.parentIndex}:${end.textOffset}`
|
|
61
|
+
: 'unknown';
|
|
62
|
+
return `selection:${startKey}:${endKey}:${selectedText}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
type MathAnnotationTarget = {
|
|
50
66
|
element: HTMLElement;
|
|
51
67
|
blockId: string;
|
|
@@ -859,6 +875,7 @@ export function useAnnotationHighlighter({
|
|
|
859
875
|
contextText: source.text.slice(0, 80),
|
|
860
876
|
selectedText: source.text,
|
|
861
877
|
source,
|
|
878
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
862
879
|
});
|
|
863
880
|
} else if (effectiveMode === 'quickLabel') {
|
|
864
881
|
pendingSourceRef.current = source;
|
|
@@ -944,6 +961,7 @@ export function useAnnotationHighlighter({
|
|
|
944
961
|
contextText: source.text.slice(0, 80),
|
|
945
962
|
selectedText: source.text,
|
|
946
963
|
source,
|
|
964
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
947
965
|
});
|
|
948
966
|
return;
|
|
949
967
|
}
|
|
@@ -1061,6 +1079,7 @@ export function useAnnotationHighlighter({
|
|
|
1061
1079
|
contextText: source.text.slice(0, 80),
|
|
1062
1080
|
selectedText: source.text,
|
|
1063
1081
|
source,
|
|
1082
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
1064
1083
|
});
|
|
1065
1084
|
return;
|
|
1066
1085
|
}
|
|
@@ -1204,6 +1223,7 @@ export function useAnnotationHighlighter({
|
|
|
1204
1223
|
selectedText: toolbarState.selectionText,
|
|
1205
1224
|
initialText: initialChar,
|
|
1206
1225
|
source: toolbarState.source,
|
|
1226
|
+
draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText),
|
|
1207
1227
|
});
|
|
1208
1228
|
setToolbarState(null);
|
|
1209
1229
|
};
|
package/hooks/useIsMobile.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Maximum CSS viewport width that can enter Plannotator's compact touch shell. */
|
|
4
|
+
export const COMPACT_TOUCH_LAYOUT_MAX_WIDTH = 1024;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canonical media query for the compact application shell.
|
|
8
|
+
*
|
|
9
|
+
* The primary pointer is intentional: `any-pointer: coarse` also matches
|
|
10
|
+
* touchscreen laptops whose primary mouse or trackpad needs the desktop shell.
|
|
11
|
+
*/
|
|
12
|
+
export const COMPACT_TOUCH_LAYOUT_MEDIA_QUERY =
|
|
13
|
+
`(max-width: ${COMPACT_TOUCH_LAYOUT_MAX_WIDTH}px) and (pointer: coarse)`;
|
|
14
|
+
|
|
3
15
|
export function useIsMobile(breakpoint = 768): boolean {
|
|
4
16
|
const [isMobile, setIsMobile] = useState(
|
|
5
17
|
() => typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
|
|
@@ -15,3 +27,28 @@ export function useIsMobile(breakpoint = 768): boolean {
|
|
|
15
27
|
|
|
16
28
|
return isMobile;
|
|
17
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports whether the current viewport needs Plannotator's compact touch shell.
|
|
33
|
+
* Plan and Code Review must share this decision so responsive chrome and scroll
|
|
34
|
+
* ownership cannot diverge on hybrid devices.
|
|
35
|
+
*/
|
|
36
|
+
export function useCompactTouchLayout(): boolean {
|
|
37
|
+
const [isCompactTouchLayout, setIsCompactTouchLayout] = useState(
|
|
38
|
+
() => typeof window !== 'undefined'
|
|
39
|
+
&& window.matchMedia(COMPACT_TOUCH_LAYOUT_MEDIA_QUERY).matches,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const mediaQuery = window.matchMedia(COMPACT_TOUCH_LAYOUT_MEDIA_QUERY);
|
|
44
|
+
const onChange = (event: MediaQueryListEvent) => {
|
|
45
|
+
setIsCompactTouchLayout(event.matches);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
mediaQuery.addEventListener('change', onChange);
|
|
49
|
+
setIsCompactTouchLayout(mediaQuery.matches);
|
|
50
|
+
return () => mediaQuery.removeEventListener('change', onChange);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return isCompactTouchLayout;
|
|
54
|
+
}
|
package/hooks/useLinkedDoc.ts
CHANGED
|
@@ -90,6 +90,9 @@ export interface UseLinkedDocOptions {
|
|
|
90
90
|
/** Let the host initialize/restore editable document state and optionally
|
|
91
91
|
* override the markdown displayed for this file. */
|
|
92
92
|
onDocumentLoaded?: (doc: LinkedDocLoadData) => string | undefined;
|
|
93
|
+
/** Notify the host after any fetched or already-loaded destination has been
|
|
94
|
+
* activated, including HTML documents and backlinks to the source. */
|
|
95
|
+
onDocumentActivated?: (doc: LinkedDocLoadData & { filepath: string }) => void;
|
|
93
96
|
/** Read current host-owned text when caching a linked doc. */
|
|
94
97
|
getDocumentMarkdown?: (filepath: string, fallback?: string) => string | undefined;
|
|
95
98
|
/** Let the host restore any state that was suspended while a linked doc was active. */
|
|
@@ -185,6 +188,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
185
188
|
sourceConverted,
|
|
186
189
|
onBeforeNavigate,
|
|
187
190
|
onDocumentLoaded,
|
|
191
|
+
onDocumentActivated,
|
|
188
192
|
getDocumentMarkdown,
|
|
189
193
|
onAfterBack,
|
|
190
194
|
} = options;
|
|
@@ -292,6 +296,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
292
296
|
// annotations intact.
|
|
293
297
|
if (sourceFilePath && data.filepath === sourceFilePath && savedPlanState.current) {
|
|
294
298
|
back();
|
|
299
|
+
onDocumentActivated?.(data);
|
|
295
300
|
return;
|
|
296
301
|
}
|
|
297
302
|
|
|
@@ -364,6 +369,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
364
369
|
});
|
|
365
370
|
setError(null);
|
|
366
371
|
sidebar.open(targetTab ?? "toc");
|
|
372
|
+
onDocumentActivated?.(data);
|
|
367
373
|
|
|
368
374
|
// Re-apply cached annotations after DOM settles
|
|
369
375
|
if (cached?.annotations.length) {
|
|
@@ -393,6 +399,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
393
399
|
sourceFilePath,
|
|
394
400
|
onBeforeNavigate,
|
|
395
401
|
onDocumentLoaded,
|
|
402
|
+
onDocumentActivated,
|
|
396
403
|
getDocumentMarkdown,
|
|
397
404
|
back,
|
|
398
405
|
]);
|
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
import { createContext, useContext, createElement, type ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Return whether this element is the browser's page-scrolling element. */
|
|
4
|
+
export function isDocumentScrollViewport(
|
|
5
|
+
viewport: HTMLElement | null,
|
|
6
|
+
): boolean {
|
|
7
|
+
return viewport !== null
|
|
8
|
+
&& viewport.ownerDocument.scrollingElement === viewport;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve the document scroller without assuming whether WebKit chose html or body. */
|
|
12
|
+
export function getDocumentScrollViewport(
|
|
13
|
+
targetDocument: Document = document,
|
|
14
|
+
): HTMLElement | null {
|
|
15
|
+
return targetDocument.scrollingElement as HTMLElement | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Page scrolling needs viewport geometry, not the document element's full
|
|
20
|
+
* content rect. Element-backed scroll areas retain their incumbent geometry.
|
|
21
|
+
*/
|
|
22
|
+
export function getScrollViewportRect(viewport: HTMLElement): DOMRect {
|
|
23
|
+
if (!isDocumentScrollViewport(viewport)) return viewport.getBoundingClientRect();
|
|
24
|
+
|
|
25
|
+
const targetWindow = viewport.ownerDocument.defaultView;
|
|
26
|
+
const visualViewport = targetWindow?.visualViewport;
|
|
27
|
+
const left = visualViewport?.offsetLeft ?? 0;
|
|
28
|
+
const top = visualViewport?.offsetTop ?? 0;
|
|
29
|
+
const width = visualViewport?.width ?? targetWindow?.innerWidth ?? viewport.clientWidth;
|
|
30
|
+
const height = visualViewport?.height ?? targetWindow?.innerHeight ?? viewport.clientHeight;
|
|
31
|
+
const DOMRectConstructor = targetWindow?.DOMRect ?? DOMRect;
|
|
32
|
+
return new DOMRectConstructor(left, top, width, height);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getScrollViewportTop(viewport: HTMLElement): number {
|
|
36
|
+
if (!isDocumentScrollViewport(viewport)) return viewport.scrollTop;
|
|
37
|
+
return viewport.ownerDocument.defaultView?.scrollY ?? viewport.scrollTop;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function scrollViewportTo(
|
|
41
|
+
viewport: HTMLElement,
|
|
42
|
+
options: ScrollToOptions,
|
|
43
|
+
): void {
|
|
44
|
+
if (!isDocumentScrollViewport(viewport)) {
|
|
45
|
+
viewport.scrollTo(options);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
viewport.ownerDocument.defaultView?.scrollTo(options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function offsetScrollViewport(viewport: HTMLElement, delta: number): void {
|
|
52
|
+
if (!isDocumentScrollViewport(viewport)) {
|
|
53
|
+
viewport.scrollTop += delta;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
viewport.ownerDocument.defaultView?.scrollBy({ top: delta, behavior: 'auto' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function addScrollViewportListener(
|
|
60
|
+
viewport: HTMLElement,
|
|
61
|
+
listener: EventListener,
|
|
62
|
+
): () => void {
|
|
63
|
+
const target: EventTarget = isDocumentScrollViewport(viewport)
|
|
64
|
+
? viewport.ownerDocument.defaultView ?? viewport
|
|
65
|
+
: viewport;
|
|
66
|
+
target.addEventListener('scroll', listener, { passive: true });
|
|
67
|
+
return () => target.removeEventListener('scroll', listener);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A document scroll uses the browser viewport as its IntersectionObserver root. */
|
|
71
|
+
export function getScrollViewportIntersectionRoot(
|
|
72
|
+
viewport: HTMLElement,
|
|
73
|
+
): Element | null {
|
|
74
|
+
return isDocumentScrollViewport(viewport) ? null : viewport;
|
|
75
|
+
}
|
|
76
|
+
|
|
3
77
|
/**
|
|
4
78
|
* Provides the currently-active scroll viewport element to descendants.
|
|
5
79
|
*
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface VisualViewportSnapshot {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
offsetTop: number;
|
|
7
|
+
offsetLeft: number;
|
|
8
|
+
scale: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ViewportEnvironmentInput {
|
|
12
|
+
layoutWidth: number;
|
|
13
|
+
layoutHeight: number;
|
|
14
|
+
visualViewport?: VisualViewportSnapshot | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ViewportEnvironment {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
offsetTop: number;
|
|
21
|
+
offsetLeft: number;
|
|
22
|
+
keyboardInset: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ViewportEdgeInsets {
|
|
26
|
+
top: number;
|
|
27
|
+
right: number;
|
|
28
|
+
bottom: number;
|
|
29
|
+
left: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VisibleViewportBounds {
|
|
33
|
+
top: number;
|
|
34
|
+
right: number;
|
|
35
|
+
bottom: number;
|
|
36
|
+
left: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ZERO_ENVIRONMENT: ViewportEnvironment = {
|
|
42
|
+
width: 0,
|
|
43
|
+
height: 0,
|
|
44
|
+
offsetTop: 0,
|
|
45
|
+
offsetLeft: 0,
|
|
46
|
+
keyboardInset: 0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ZERO_INSETS: ViewportEdgeInsets = {
|
|
50
|
+
top: 0,
|
|
51
|
+
right: 0,
|
|
52
|
+
bottom: 0,
|
|
53
|
+
left: 0,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const VIEWPORT_PROPERTIES = [
|
|
57
|
+
'--pn-viewport-width',
|
|
58
|
+
'--pn-viewport-height',
|
|
59
|
+
'--pn-viewport-offset-top',
|
|
60
|
+
'--pn-viewport-offset-left',
|
|
61
|
+
'--pn-keyboard-inset',
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
type ViewportProperty = (typeof VIEWPORT_PROPERTIES)[number];
|
|
65
|
+
|
|
66
|
+
let subscriberCount = 0;
|
|
67
|
+
let stopObserving: (() => void) | null = null;
|
|
68
|
+
let currentEnvironment: ViewportEnvironment | null = null;
|
|
69
|
+
const environmentListeners = new Set<() => void>();
|
|
70
|
+
|
|
71
|
+
function finiteOr(value: number, fallback: number): number {
|
|
72
|
+
return Number.isFinite(value) ? value : fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function positiveOr(value: number, fallback: number): number {
|
|
76
|
+
const finite = finiteOr(value, fallback);
|
|
77
|
+
return finite > 0 ? finite : fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rounded(value: number): number {
|
|
81
|
+
return Math.round(value * 100) / 100;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Converts the visual viewport into application-stage geometry. Pinch zoom is
|
|
86
|
+
* intentionally represented by offsets only: reshaping the app while a user
|
|
87
|
+
* zooms and pans would fight accessibility zoom. At the normal scale, browser
|
|
88
|
+
* chrome and the software keyboard are allowed to reduce the usable stage.
|
|
89
|
+
*/
|
|
90
|
+
export function calculateViewportEnvironment({
|
|
91
|
+
layoutWidth,
|
|
92
|
+
layoutHeight,
|
|
93
|
+
visualViewport,
|
|
94
|
+
}: ViewportEnvironmentInput): ViewportEnvironment {
|
|
95
|
+
const safeLayoutWidth = Math.max(0, finiteOr(layoutWidth, 0));
|
|
96
|
+
const safeLayoutHeight = Math.max(0, finiteOr(layoutHeight, 0));
|
|
97
|
+
|
|
98
|
+
if (!visualViewport) {
|
|
99
|
+
return {
|
|
100
|
+
width: rounded(safeLayoutWidth),
|
|
101
|
+
height: rounded(safeLayoutHeight),
|
|
102
|
+
offsetTop: 0,
|
|
103
|
+
offsetLeft: 0,
|
|
104
|
+
keyboardInset: 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const scale = positiveOr(visualViewport.scale, 1);
|
|
109
|
+
const offsetTop = Math.max(0, finiteOr(visualViewport.offsetTop, 0));
|
|
110
|
+
const offsetLeft = Math.max(0, finiteOr(visualViewport.offsetLeft, 0));
|
|
111
|
+
const isPinchZoomed = Math.abs(scale - 1) > 0.01;
|
|
112
|
+
if (isPinchZoomed) {
|
|
113
|
+
return {
|
|
114
|
+
width: rounded(safeLayoutWidth),
|
|
115
|
+
height: rounded(safeLayoutHeight),
|
|
116
|
+
offsetTop: rounded(offsetTop),
|
|
117
|
+
offsetLeft: rounded(offsetLeft),
|
|
118
|
+
keyboardInset: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const scaledWidth = positiveOr(visualViewport.width, safeLayoutWidth);
|
|
123
|
+
const scaledHeight = positiveOr(visualViewport.height, safeLayoutHeight);
|
|
124
|
+
const availableWidth = Math.max(0, safeLayoutWidth - offsetLeft);
|
|
125
|
+
const availableHeight = Math.max(0, safeLayoutHeight - offsetTop);
|
|
126
|
+
const width = safeLayoutWidth > 0 ? Math.min(scaledWidth, availableWidth) : scaledWidth;
|
|
127
|
+
const height = safeLayoutHeight > 0 ? Math.min(scaledHeight, availableHeight) : scaledHeight;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
width: rounded(Math.max(0, width)),
|
|
131
|
+
height: rounded(Math.max(0, height)),
|
|
132
|
+
offsetTop: rounded(offsetTop),
|
|
133
|
+
offsetLeft: rounded(offsetLeft),
|
|
134
|
+
keyboardInset: rounded(Math.max(0, safeLayoutHeight - offsetTop - height)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Converts the observed viewport into usable fixed-position bounds. Padding
|
|
140
|
+
* and safe-area insets are inputs so positioning remains deterministic and
|
|
141
|
+
* testable instead of reading CSS environment variables in every overlay.
|
|
142
|
+
*/
|
|
143
|
+
export function calculateVisibleViewportBounds(
|
|
144
|
+
environment: ViewportEnvironment,
|
|
145
|
+
edgePadding = 0,
|
|
146
|
+
insets: Partial<ViewportEdgeInsets> = ZERO_INSETS,
|
|
147
|
+
): VisibleViewportBounds {
|
|
148
|
+
const padding = Math.max(0, finiteOr(edgePadding, 0));
|
|
149
|
+
const safeInsets = {
|
|
150
|
+
top: Math.max(0, finiteOr(insets.top ?? 0, 0)),
|
|
151
|
+
right: Math.max(0, finiteOr(insets.right ?? 0, 0)),
|
|
152
|
+
bottom: Math.max(0, finiteOr(insets.bottom ?? 0, 0)),
|
|
153
|
+
left: Math.max(0, finiteOr(insets.left ?? 0, 0)),
|
|
154
|
+
};
|
|
155
|
+
const left = environment.offsetLeft + padding + safeInsets.left;
|
|
156
|
+
const top = environment.offsetTop + padding + safeInsets.top;
|
|
157
|
+
const right = Math.max(
|
|
158
|
+
left,
|
|
159
|
+
environment.offsetLeft + environment.width - padding - safeInsets.right,
|
|
160
|
+
);
|
|
161
|
+
const bottom = Math.max(
|
|
162
|
+
top,
|
|
163
|
+
environment.offsetTop + environment.height - padding - safeInsets.bottom,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
top: rounded(top),
|
|
168
|
+
right: rounded(right),
|
|
169
|
+
bottom: rounded(bottom),
|
|
170
|
+
left: rounded(left),
|
|
171
|
+
width: rounded(Math.max(0, right - left)),
|
|
172
|
+
height: rounded(Math.max(0, bottom - top)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function shouldUseExpandedComposer({
|
|
177
|
+
bounds,
|
|
178
|
+
coarsePointer,
|
|
179
|
+
}: {
|
|
180
|
+
bounds: VisibleViewportBounds;
|
|
181
|
+
coarsePointer: boolean;
|
|
182
|
+
}): boolean {
|
|
183
|
+
return coarsePointer || bounds.width < 640 || bounds.height < 420;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Returns whether the device's primary pointing input is coarse. */
|
|
187
|
+
export function hasPrimaryCoarsePointer(targetWindow?: Window): boolean {
|
|
188
|
+
const resolvedWindow = targetWindow ?? (typeof window === 'undefined' ? undefined : window);
|
|
189
|
+
if (!resolvedWindow?.matchMedia) return false;
|
|
190
|
+
return resolvedWindow.matchMedia('(pointer: coarse)').matches;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readViewportEnvironment(targetWindow: Window): ViewportEnvironment {
|
|
194
|
+
const visualViewport = targetWindow.visualViewport;
|
|
195
|
+
return calculateViewportEnvironment({
|
|
196
|
+
layoutWidth: targetWindow.innerWidth,
|
|
197
|
+
layoutHeight: targetWindow.innerHeight,
|
|
198
|
+
visualViewport: visualViewport
|
|
199
|
+
? {
|
|
200
|
+
width: visualViewport.width,
|
|
201
|
+
height: visualViewport.height,
|
|
202
|
+
offsetTop: visualViewport.offsetTop,
|
|
203
|
+
offsetLeft: visualViewport.offsetLeft,
|
|
204
|
+
scale: visualViewport.scale,
|
|
205
|
+
}
|
|
206
|
+
: null,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function environmentsEqual(
|
|
211
|
+
left: ViewportEnvironment | null,
|
|
212
|
+
right: ViewportEnvironment,
|
|
213
|
+
): boolean {
|
|
214
|
+
return !!left
|
|
215
|
+
&& left.width === right.width
|
|
216
|
+
&& left.height === right.height
|
|
217
|
+
&& left.offsetTop === right.offsetTop
|
|
218
|
+
&& left.offsetLeft === right.offsetLeft
|
|
219
|
+
&& left.keyboardInset === right.keyboardInset;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getViewportEnvironmentSnapshot(): ViewportEnvironment {
|
|
223
|
+
if (currentEnvironment) return currentEnvironment;
|
|
224
|
+
if (typeof window === 'undefined') return ZERO_ENVIRONMENT;
|
|
225
|
+
currentEnvironment = readViewportEnvironment(window);
|
|
226
|
+
return currentEnvironment;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function cssValues(environment: ViewportEnvironment): Record<ViewportProperty, string> {
|
|
230
|
+
return {
|
|
231
|
+
'--pn-viewport-width': `${environment.width}px`,
|
|
232
|
+
'--pn-viewport-height': `${environment.height}px`,
|
|
233
|
+
'--pn-viewport-offset-top': `${environment.offsetTop}px`,
|
|
234
|
+
'--pn-viewport-offset-left': `${environment.offsetLeft}px`,
|
|
235
|
+
'--pn-keyboard-inset': `${environment.keyboardInset}px`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function startViewportEnvironmentObserver(
|
|
240
|
+
targetWindow: Window,
|
|
241
|
+
targetDocument: Document,
|
|
242
|
+
): () => void {
|
|
243
|
+
const rootStyle = targetDocument.documentElement.style;
|
|
244
|
+
const previousValues = new Map<ViewportProperty, string>();
|
|
245
|
+
const writtenValues = new Map<ViewportProperty, string>();
|
|
246
|
+
for (const property of VIEWPORT_PROPERTIES) {
|
|
247
|
+
previousValues.set(property, rootStyle.getPropertyValue(property));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let animationFrame: number | null = null;
|
|
251
|
+
|
|
252
|
+
const write = () => {
|
|
253
|
+
animationFrame = null;
|
|
254
|
+
const nextEnvironment = readViewportEnvironment(targetWindow);
|
|
255
|
+
const changed = !environmentsEqual(currentEnvironment, nextEnvironment);
|
|
256
|
+
currentEnvironment = nextEnvironment;
|
|
257
|
+
const nextValues = cssValues(nextEnvironment);
|
|
258
|
+
for (const property of VIEWPORT_PROPERTIES) {
|
|
259
|
+
const nextValue = nextValues[property];
|
|
260
|
+
if (writtenValues.get(property) === nextValue) continue;
|
|
261
|
+
rootStyle.setProperty(property, nextValue);
|
|
262
|
+
writtenValues.set(property, nextValue);
|
|
263
|
+
}
|
|
264
|
+
if (changed) environmentListeners.forEach(listener => listener());
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const scheduleWrite = () => {
|
|
268
|
+
if (animationFrame !== null) return;
|
|
269
|
+
animationFrame = targetWindow.requestAnimationFrame(write);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const visualViewport = targetWindow.visualViewport;
|
|
273
|
+
targetWindow.addEventListener('resize', scheduleWrite);
|
|
274
|
+
targetWindow.addEventListener('orientationchange', scheduleWrite);
|
|
275
|
+
targetWindow.addEventListener('pageshow', scheduleWrite);
|
|
276
|
+
targetDocument.addEventListener('visibilitychange', scheduleWrite);
|
|
277
|
+
visualViewport?.addEventListener('resize', scheduleWrite);
|
|
278
|
+
visualViewport?.addEventListener('scroll', scheduleWrite);
|
|
279
|
+
write();
|
|
280
|
+
|
|
281
|
+
return () => {
|
|
282
|
+
targetWindow.removeEventListener('resize', scheduleWrite);
|
|
283
|
+
targetWindow.removeEventListener('orientationchange', scheduleWrite);
|
|
284
|
+
targetWindow.removeEventListener('pageshow', scheduleWrite);
|
|
285
|
+
targetDocument.removeEventListener('visibilitychange', scheduleWrite);
|
|
286
|
+
visualViewport?.removeEventListener('resize', scheduleWrite);
|
|
287
|
+
visualViewport?.removeEventListener('scroll', scheduleWrite);
|
|
288
|
+
if (animationFrame !== null) targetWindow.cancelAnimationFrame(animationFrame);
|
|
289
|
+
|
|
290
|
+
for (const property of VIEWPORT_PROPERTIES) {
|
|
291
|
+
const previousValue = previousValues.get(property) ?? '';
|
|
292
|
+
if (previousValue) rootStyle.setProperty(property, previousValue);
|
|
293
|
+
else rootStyle.removeProperty(property);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function acquireViewportEnvironment(): () => void {
|
|
299
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
|
|
300
|
+
|
|
301
|
+
subscriberCount += 1;
|
|
302
|
+
if (subscriberCount === 1) {
|
|
303
|
+
stopObserving = startViewportEnvironmentObserver(window, document);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let released = false;
|
|
307
|
+
return () => {
|
|
308
|
+
if (released) return;
|
|
309
|
+
released = true;
|
|
310
|
+
subscriberCount = Math.max(0, subscriberCount - 1);
|
|
311
|
+
if (subscriberCount !== 0) return;
|
|
312
|
+
stopObserving?.();
|
|
313
|
+
stopObserving = null;
|
|
314
|
+
currentEnvironment = null;
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function subscribeViewportEnvironment(listener: () => void): () => void {
|
|
319
|
+
environmentListeners.add(listener);
|
|
320
|
+
const release = acquireViewportEnvironment();
|
|
321
|
+
return () => {
|
|
322
|
+
environmentListeners.delete(listener);
|
|
323
|
+
release();
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Keeps Plannotator's shared viewport CSS properties synchronized without
|
|
329
|
+
* putting high-frequency browser geometry into React state.
|
|
330
|
+
*/
|
|
331
|
+
export function useViewportEnvironment(): void {
|
|
332
|
+
useEffect(() => acquireViewportEnvironment(), []);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Reactive bounds for fixed overlays. It shares the root observer and its
|
|
337
|
+
* animation-frame coalescing, so composer consumers do not add parallel
|
|
338
|
+
* Visual Viewport listeners.
|
|
339
|
+
*/
|
|
340
|
+
export function useVisibleViewportBounds(edgePadding = 0): VisibleViewportBounds {
|
|
341
|
+
const environment = useSyncExternalStore(
|
|
342
|
+
subscribeViewportEnvironment,
|
|
343
|
+
getViewportEnvironmentSnapshot,
|
|
344
|
+
() => ZERO_ENVIRONMENT,
|
|
345
|
+
);
|
|
346
|
+
return useMemo(
|
|
347
|
+
() => calculateVisibleViewportBounds(environment, edgePadding),
|
|
348
|
+
[edgePadding, environment],
|
|
349
|
+
);
|
|
350
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./components/goal-setup/*": "./components/goal-setup/*.tsx",
|
|
11
11
|
"./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx",
|
|
12
12
|
"./components/html-viewer": "./components/html-viewer/index.ts",
|
|
13
|
+
"./components/html-viewer/bridge-script": "./components/html-viewer/bridge-script.ts",
|
|
13
14
|
"./components/sidebar/*": "./components/sidebar/*.tsx",
|
|
14
15
|
"./components/plan-diff/*": "./components/plan-diff/*.tsx",
|
|
15
16
|
"./utils/*": "./utils/*.ts",
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"@lezer/highlight": "^1.2.3",
|
|
69
70
|
"@pierre/diffs": "1.3.2",
|
|
70
71
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
71
|
-
"@plannotator/core": "0.
|
|
72
|
+
"@plannotator/core": "0.24.0",
|
|
72
73
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
73
74
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
74
75
|
"@tanstack/react-table": "^8.21.3",
|
package/shortcuts/index.ts
CHANGED
|
@@ -2,12 +2,15 @@ export * from './core';
|
|
|
2
2
|
export * from './runtime';
|
|
3
3
|
|
|
4
4
|
// plan-review scopes
|
|
5
|
+
export { annotationModeShortcuts, useAnnotationModeShortcuts } from './plan-review/annotationMode.shortcuts';
|
|
5
6
|
export { annotationToolbarShortcuts, useAnnotationToolbarShortcuts } from './plan-review/annotationToolbar.shortcuts';
|
|
6
7
|
export { annotationPanelShortcuts, useAnnotationPanelShortcuts } from './plan-review/annotationPanel.shortcuts';
|
|
7
8
|
export { commentPopoverShortcuts } from './plan-review/commentPopover.shortcuts';
|
|
8
9
|
export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-review/imageAnnotator.shortcuts';
|
|
9
10
|
export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts';
|
|
11
|
+
export { htmlAnnotateShortcuts, useHtmlAnnotateShortcuts } from './plan-review/htmlAnnotate.shortcuts';
|
|
10
12
|
export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts';
|
|
13
|
+
export { documentViewShortcuts, useDocumentViewShortcuts } from './plan-review/documentView.shortcuts';
|
|
11
14
|
export {
|
|
12
15
|
describeVimSelectionAction,
|
|
13
16
|
isVimSelectionActionId,
|