@plannotator/ui 0.40.0 → 0.41.1
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 +110 -5
- package/README.md +6 -2
- package/components/DiagramBlock.tsx +363 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/MermaidBlock.tsx +17 -664
- package/components/Viewer.tsx +60 -5
- package/components/diagram/DiagramCanvas.tsx +427 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPending.tsx +60 -0
- package/components/diagram/DiagramPopout.tsx +72 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +285 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/diagramControls.ts +41 -0
- package/components/diagram/index.ts +39 -0
- package/components/diagram/svgContentSize.ts +47 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/bridge-script.asset.js +25 -0
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +37 -0
- package/hooks/useAnnotationHighlighter.ts +5 -0
- package/package.json +5 -3
- package/styles.css +1 -1
- package/types.ts +5 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/parser.ts +15 -1
- package/components/mermaidSvg.ts +0 -33
package/components/Viewer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateId } from '../utils/generateId';
|
|
2
|
-
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
|
2
|
+
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
|
|
5
5
|
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
|
|
@@ -18,6 +18,20 @@ import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
|
|
|
18
18
|
import { AnnotationToolbar } from './AnnotationToolbar';
|
|
19
19
|
import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The diagram engine — the renderer slot, the canvas, the comment overlay,
|
|
23
|
+
* the popout, and (through the viewer) CodeMirror — is loaded by the first
|
|
24
|
+
* diagram fence in the document and by nothing else. A markdown document
|
|
25
|
+
* with no diagram never reaches for it; a chunked host that statically
|
|
26
|
+
* imports this Viewer pays none of it on a plain document read.
|
|
27
|
+
*
|
|
28
|
+
* The Suspense fallback is the SAME pending state the block itself shows
|
|
29
|
+
* while its engine loads (`DiagramPending`), inside the same boxes, so the
|
|
30
|
+
* source fence paints once and the two waits read as one.
|
|
31
|
+
*/
|
|
32
|
+
const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock }));
|
|
33
|
+
const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock }));
|
|
34
|
+
|
|
21
35
|
// Debug error boundary to catch silent toolbar crashes
|
|
22
36
|
class ToolbarErrorBoundary extends React.Component<
|
|
23
37
|
{ children: React.ReactNode },
|
|
@@ -40,8 +54,8 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
|
|
|
40
54
|
import { TaterSpriteSitting } from './TaterSpriteSitting';
|
|
41
55
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
42
56
|
import { MessagesIcon } from './icons/MessagesIcon';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
57
|
+
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
58
|
+
import { DiagramBlockPending } from './diagram/DiagramPending';
|
|
45
59
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
46
60
|
import { getIdentity } from '../utils/identity';
|
|
47
61
|
import { type QuickLabel } from '../utils/quickLabels';
|
|
@@ -1042,8 +1056,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1042
1056
|
</>
|
|
1043
1057
|
);
|
|
1044
1058
|
|
|
1059
|
+
// The document's diagram blocks, in order: a diagram comment that names
|
|
1060
|
+
// none of them (an external POST, a deleted fence) is resolved by anchor
|
|
1061
|
+
// against each, first resolver wins (see diagram/anchorClaims).
|
|
1062
|
+
const diagramBlockKey = blocks
|
|
1063
|
+
.filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
|
|
1064
|
+
.map((b) => b.id)
|
|
1065
|
+
.join('\n');
|
|
1066
|
+
const diagramClaims = useMemo(
|
|
1067
|
+
() => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
|
|
1068
|
+
[diagramBlockKey],
|
|
1069
|
+
);
|
|
1070
|
+
// With no diagram in the document nobody can resolve a diagram comment:
|
|
1071
|
+
// it is unanchored, and the highlighter (which skips it) will not say so.
|
|
1072
|
+
useEffect(() => {
|
|
1073
|
+
if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
|
|
1074
|
+
const ids = annotations.filter((ann) => ann.diagramAnchor !== undefined).map((ann) => ann.id);
|
|
1075
|
+
if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
|
|
1076
|
+
}, [annotations, diagramBlockKey, onRestoreReport]);
|
|
1077
|
+
|
|
1045
1078
|
return (
|
|
1046
1079
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
1080
|
+
<DiagramAnchorClaimsContext.Provider value={diagramClaims}>
|
|
1047
1081
|
<div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
|
|
1048
1082
|
{taterMode && <TaterSpriteSitting />}
|
|
1049
1083
|
<article
|
|
@@ -1147,9 +1181,29 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1147
1181
|
);
|
|
1148
1182
|
})()
|
|
1149
1183
|
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
|
|
1150
|
-
<
|
|
1184
|
+
<Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
|
|
1185
|
+
<MermaidBlock
|
|
1186
|
+
block={group.block}
|
|
1187
|
+
annotations={annotations}
|
|
1188
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1189
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1190
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1191
|
+
readOnly={readOnly}
|
|
1192
|
+
onRestoreReport={onRestoreReport}
|
|
1193
|
+
/>
|
|
1194
|
+
</Suspense>
|
|
1151
1195
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
1152
|
-
<
|
|
1196
|
+
<Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
|
|
1197
|
+
<GraphvizBlock
|
|
1198
|
+
block={group.block}
|
|
1199
|
+
annotations={annotations}
|
|
1200
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1201
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1202
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1203
|
+
readOnly={readOnly}
|
|
1204
|
+
onRestoreReport={onRestoreReport}
|
|
1205
|
+
/>
|
|
1206
|
+
</Suspense>
|
|
1153
1207
|
) : group.block.type === 'table' ? (
|
|
1154
1208
|
<TableBlock
|
|
1155
1209
|
key={group.block.id}
|
|
@@ -1444,6 +1498,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1444
1498
|
document.body
|
|
1445
1499
|
)}
|
|
1446
1500
|
</div>
|
|
1501
|
+
</DiagramAnchorClaimsContext.Provider>
|
|
1447
1502
|
</CodePathValidationContext.Provider>
|
|
1448
1503
|
);
|
|
1449
1504
|
});
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { Maximize2, Minus, Plus } from 'lucide-react';
|
|
2
|
+
import React, {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
10
|
+
type PointerEvent as ReactPointerEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { cn } from '../../lib/utils';
|
|
14
|
+
import { diagramHitSource } from '../../utils/diagram-render';
|
|
15
|
+
import { isMac, isModKeyHeld } from '../../utils/platform';
|
|
16
|
+
import { Button } from '../ui/button';
|
|
17
|
+
import { isDiagramControlEvent } from './diagramControls';
|
|
18
|
+
import { svgContentSize } from './svgContentSize';
|
|
19
|
+
import {
|
|
20
|
+
DRAG_THRESHOLD_PX,
|
|
21
|
+
TOUCH_DRAG_THRESHOLD_PX,
|
|
22
|
+
useDiagramViewport,
|
|
23
|
+
WHEEL_ZOOM_SENSITIVITY,
|
|
24
|
+
ZOOM_STEP,
|
|
25
|
+
type ContentSize,
|
|
26
|
+
type Viewport,
|
|
27
|
+
} from './useDiagramViewport';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canvas: the rendered svg inside a transformed wrapper, edge to edge in
|
|
31
|
+
* its host. Wheel and pinch zoom about the pointer, drag pans, `+` `-` `0`
|
|
32
|
+
* (fit) and the arrow keys on the keyboard, a small control in the corner.
|
|
33
|
+
* Click-to-select, drag-to-pan: a press that does not travel the drag
|
|
34
|
+
* threshold (4 px for a mouse or pen, 10 px for a finger) is a click and
|
|
35
|
+
* opens the composer; one that travels is a pan and never opens it. Nothing
|
|
36
|
+
* highlights on a plain mouse-over (the owner removed hover targeting: it
|
|
37
|
+
* read as messy and fought the pan hand); the only pre-click affordance is
|
|
38
|
+
* the ring under the pointer while the platform modifier is held, and it
|
|
39
|
+
* disarms on the key's release, on any other key, and on window blur.
|
|
40
|
+
*
|
|
41
|
+
* What a click means is decided over EVERYTHING under the pointer
|
|
42
|
+
* (`elementsFromPoint`), never the topmost element alone: every edge has an
|
|
43
|
+
* invisible 14 px hit path in a layer above the diagram (the render slot's
|
|
44
|
+
* `widenEdgeHitAreas`), so where an edge meets a node both are under the
|
|
45
|
+
* pointer, and the caller's `pickTarget` takes the node first, then an edge,
|
|
46
|
+
* then a cluster.
|
|
47
|
+
*
|
|
48
|
+
* In the document flow the canvas lets a finger scroll the page vertically
|
|
49
|
+
* (`touch-action: pan-y`); a full-size host that owns the screen passes
|
|
50
|
+
* `touch-none` through `className`. Under strict Mermaid disables click
|
|
51
|
+
* callbacks, and a `click A "https://..."` link binding still renders an
|
|
52
|
+
* `<a href>` that the render slot's sanitizer strips, so the canvas owns
|
|
53
|
+
* every click and there is no armed switch.
|
|
54
|
+
*
|
|
55
|
+
* Chrome painted OVER the canvas (the zoom strip, the composer, the source
|
|
56
|
+
* pane, the popout's header) is never a diagram target: a pointer event
|
|
57
|
+
* whose composed path contains a control resolves nothing, opens no
|
|
58
|
+
* composer and starts no pan (`diagramControls.ts`). Without that the
|
|
59
|
+
* `elementsFromPoint` walk stepped past the control to the part behind it,
|
|
60
|
+
* so pressing Zoom out over a node opened the composer on that node.
|
|
61
|
+
*
|
|
62
|
+
* The render slot hands over a sanitized svg NODE, not markup: the wrapper
|
|
63
|
+
* mounts it with `replaceChildren` once per render, so no html string ever
|
|
64
|
+
* crosses into the app DOM here. The overlay (a sibling of the wrapper,
|
|
65
|
+
* unscaled) is the caller's, rendered through `overlay` with the live
|
|
66
|
+
* viewport so rings reproject on every change.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
// `svgContentSize` is the canvas's, but the document's fence block needs it
|
|
70
|
+
// without the canvas: it lives in its own dependency-free module and is
|
|
71
|
+
// re-exported here so every published path keeps resolving.
|
|
72
|
+
export { svgContentSize } from './svgContentSize';
|
|
73
|
+
|
|
74
|
+
/** One arrow-key press pans this far (the diagram moves WITH the arrow, as
|
|
75
|
+
* a scroll would); Shift multiplies it by five. */
|
|
76
|
+
export const KEY_PAN_PX = 40;
|
|
77
|
+
|
|
78
|
+
export interface DiagramCanvasHandle {
|
|
79
|
+
readonly viewport: Viewport;
|
|
80
|
+
readonly hostRef: React.RefObject<HTMLDivElement | null>;
|
|
81
|
+
readonly panIntoView: ReturnType<typeof useDiagramViewport>['panIntoView'];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What an Escape on the canvas did: `consumed` closed something the viewer
|
|
85
|
+
* owns (a draft, a selection) and the key goes no further; `pass` lets it
|
|
86
|
+
* reach whatever holds the canvas (a popout's close). */
|
|
87
|
+
export type DiagramEscapeOutcome = 'consumed' | 'pass';
|
|
88
|
+
|
|
89
|
+
export function DiagramCanvas({
|
|
90
|
+
svgNode,
|
|
91
|
+
targetSelector,
|
|
92
|
+
pickTarget,
|
|
93
|
+
dimmed,
|
|
94
|
+
onSvgRoot,
|
|
95
|
+
onHoverElement,
|
|
96
|
+
onClickElement,
|
|
97
|
+
onEscape,
|
|
98
|
+
overlay,
|
|
99
|
+
children,
|
|
100
|
+
autoFocus,
|
|
101
|
+
className,
|
|
102
|
+
}: {
|
|
103
|
+
/** The last good render's sanitized svg root, or null before the first. */
|
|
104
|
+
svgNode: SVGSVGElement | null;
|
|
105
|
+
/** The engine's selector of every element the pointer can address
|
|
106
|
+
* (the finder's, through the renderer slot). */
|
|
107
|
+
targetSelector: string;
|
|
108
|
+
/** Choose among the addressable elements under the pointer, topmost
|
|
109
|
+
* first (the viewer's rule: a node, then an edge, then a cluster).
|
|
110
|
+
* Default: the topmost. */
|
|
111
|
+
pickTarget?: (candidates: readonly Element[]) => Element | null;
|
|
112
|
+
/** A parse error keeps the last render under the strip, dimmed. */
|
|
113
|
+
dimmed: boolean;
|
|
114
|
+
/** The mounted svg root after each injection (null on unmount). */
|
|
115
|
+
onSvgRoot: (root: SVGSVGElement | null) => void;
|
|
116
|
+
onHoverElement: (element: Element | null) => void;
|
|
117
|
+
onClickElement: (element: Element | null, shiftKey: boolean) => void;
|
|
118
|
+
onEscape: () => DiagramEscapeOutcome;
|
|
119
|
+
/** The overlay layer, given the live viewport and host. */
|
|
120
|
+
overlay: (handle: DiagramCanvasHandle) => ReactNode;
|
|
121
|
+
children?: ReactNode;
|
|
122
|
+
/** Take the keyboard on mount so `+`, `-`, `0` and Escape work at once
|
|
123
|
+
* (a popout). Off in the document flow, where it would steal the focus
|
|
124
|
+
* from the reader. */
|
|
125
|
+
autoFocus?: boolean;
|
|
126
|
+
/** `touch-none` for a host that owns the whole screen (a popout); the
|
|
127
|
+
* default lets a finger scroll the page past an inline diagram. */
|
|
128
|
+
className?: string;
|
|
129
|
+
}) {
|
|
130
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
131
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
132
|
+
const [content, setContent] = useState<ContentSize | null>(null);
|
|
133
|
+
const { viewport, fit, zoomBy, panBy, panIntoView } = useDiagramViewport(hostRef, content);
|
|
134
|
+
|
|
135
|
+
// Mount the sanitized node once per render and size the wrapper to the
|
|
136
|
+
// diagram's own box so the transform scales real pixels.
|
|
137
|
+
useLayoutEffect(() => {
|
|
138
|
+
const wrapper = wrapperRef.current;
|
|
139
|
+
if (wrapper === null) return;
|
|
140
|
+
if (svgNode === null) {
|
|
141
|
+
wrapper.replaceChildren();
|
|
142
|
+
setContent(null);
|
|
143
|
+
onSvgRoot(null);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
wrapper.replaceChildren(svgNode);
|
|
147
|
+
const size = svgContentSize(svgNode);
|
|
148
|
+
if (size !== null) {
|
|
149
|
+
svgNode.style.width = '100%';
|
|
150
|
+
svgNode.style.height = '100%';
|
|
151
|
+
svgNode.style.maxWidth = 'none';
|
|
152
|
+
}
|
|
153
|
+
setContent(size);
|
|
154
|
+
onSvgRoot(svgNode);
|
|
155
|
+
return () => onSvgRoot(null);
|
|
156
|
+
}, [onSvgRoot, svgNode]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (autoFocus) hostRef.current?.focus();
|
|
160
|
+
}, [autoFocus]);
|
|
161
|
+
|
|
162
|
+
// Wheel zoom needs a non-passive listener (React's onWheel is passive,
|
|
163
|
+
// so preventDefault there cannot stop the page from scrolling).
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const host = hostRef.current;
|
|
166
|
+
if (host === null) return;
|
|
167
|
+
const onWheel = (event: WheelEvent) => {
|
|
168
|
+
// A sub-pixel delta (a trackpad coming to rest, a horizontal swipe)
|
|
169
|
+
// is neither a zoom nor a reason to hold the page's scroll.
|
|
170
|
+
if (Math.abs(event.deltaY) < 0.1) return;
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), event.clientX, event.clientY);
|
|
173
|
+
};
|
|
174
|
+
host.addEventListener('wheel', onWheel, { passive: false });
|
|
175
|
+
return () => host.removeEventListener('wheel', onWheel);
|
|
176
|
+
}, [zoomBy]);
|
|
177
|
+
|
|
178
|
+
const targetUnder = useCallback(
|
|
179
|
+
(event: ReactPointerEvent): Element | null => {
|
|
180
|
+
const wrapper = wrapperRef.current;
|
|
181
|
+
if (wrapper === null) return null;
|
|
182
|
+
// The zoom strip and the composer are painted OVER the canvas and are
|
|
183
|
+
// not in the svg, so the walk below would step past them to whatever
|
|
184
|
+
// part sits underneath. A press on chrome addresses no part.
|
|
185
|
+
if (isDiagramControlEvent(event)) return null;
|
|
186
|
+
// Everything under the pointer, topmost first. Without a layout
|
|
187
|
+
// engine (happy-dom) the event's own target is all there is.
|
|
188
|
+
const doc = wrapper.ownerDocument;
|
|
189
|
+
const stack =
|
|
190
|
+
typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
|
|
191
|
+
const under = stack.length > 0 ? stack : event.target instanceof Element ? [event.target] : [];
|
|
192
|
+
const candidates: Element[] = [];
|
|
193
|
+
for (const el of under) {
|
|
194
|
+
if (!wrapper.contains(el)) continue;
|
|
195
|
+
// A hit path stands for the visible edge it was made from.
|
|
196
|
+
const node = diagramHitSource(el) ?? el;
|
|
197
|
+
const target = node.closest(targetSelector);
|
|
198
|
+
if (target !== null && wrapper.contains(target) && !candidates.includes(target)) candidates.push(target);
|
|
199
|
+
}
|
|
200
|
+
if (candidates.length === 0) return null;
|
|
201
|
+
return pickTarget ? pickTarget(candidates) : (candidates[0] ?? null);
|
|
202
|
+
},
|
|
203
|
+
[pickTarget, targetSelector],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// The modifier-gated ring disarms the way the token hover cards do: on
|
|
207
|
+
// the modifier's release, on any other key while it is held (Cmd+C is a
|
|
208
|
+
// copy, not a question about the part under the pointer), and on blur.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const win = hostRef.current?.ownerDocument.defaultView;
|
|
211
|
+
if (!win) return;
|
|
212
|
+
const modKey = isMac ? 'Meta' : 'Control';
|
|
213
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
214
|
+
if (event.key === modKey) onHoverElement(null);
|
|
215
|
+
};
|
|
216
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
217
|
+
if (event.key !== modKey) onHoverElement(null);
|
|
218
|
+
};
|
|
219
|
+
const onBlur = () => onHoverElement(null);
|
|
220
|
+
win.addEventListener('keyup', onKeyUp);
|
|
221
|
+
win.addEventListener('keydown', onKeyDown);
|
|
222
|
+
win.addEventListener('blur', onBlur);
|
|
223
|
+
return () => {
|
|
224
|
+
win.removeEventListener('keyup', onKeyUp);
|
|
225
|
+
win.removeEventListener('keydown', onKeyDown);
|
|
226
|
+
win.removeEventListener('blur', onBlur);
|
|
227
|
+
};
|
|
228
|
+
}, [onHoverElement]);
|
|
229
|
+
|
|
230
|
+
// Press bookkeeping: where the pointer went down and whether it became a
|
|
231
|
+
// pan. Pointer capture keeps the pan alive past the host's edge.
|
|
232
|
+
const pressRef = useRef<{
|
|
233
|
+
id: number;
|
|
234
|
+
x: number;
|
|
235
|
+
y: number;
|
|
236
|
+
lastX: number;
|
|
237
|
+
lastY: number;
|
|
238
|
+
threshold: number;
|
|
239
|
+
panning: boolean;
|
|
240
|
+
} | null>(null);
|
|
241
|
+
const [panning, setPanning] = useState(false);
|
|
242
|
+
|
|
243
|
+
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
244
|
+
if (event.button !== 0) return;
|
|
245
|
+
// Pressing a control is not the start of a pan either.
|
|
246
|
+
if (isDiagramControlEvent(event)) {
|
|
247
|
+
pressRef.current = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
pressRef.current = {
|
|
251
|
+
id: event.pointerId,
|
|
252
|
+
x: event.clientX,
|
|
253
|
+
y: event.clientY,
|
|
254
|
+
lastX: event.clientX,
|
|
255
|
+
lastY: event.clientY,
|
|
256
|
+
// A fingertip wobbles more than a mouse: a tap must still be a tap.
|
|
257
|
+
threshold: event.pointerType === 'touch' ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX,
|
|
258
|
+
panning: false,
|
|
259
|
+
};
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const onPointerMove = useCallback(
|
|
263
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
264
|
+
const press = pressRef.current;
|
|
265
|
+
if (press === null || press.id !== event.pointerId) {
|
|
266
|
+
// Nothing highlights on a plain mouse-over; the ring under the
|
|
267
|
+
// pointer exists only under the platform modifier.
|
|
268
|
+
onHoverElement(isModKeyHeld(event) ? targetUnder(event) : null);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!press.panning) {
|
|
272
|
+
const travelled = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
|
273
|
+
if (travelled < press.threshold) return;
|
|
274
|
+
press.panning = true;
|
|
275
|
+
setPanning(true);
|
|
276
|
+
onHoverElement(null);
|
|
277
|
+
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
|
278
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
panBy(event.clientX - press.lastX, event.clientY - press.lastY);
|
|
282
|
+
press.lastX = event.clientX;
|
|
283
|
+
press.lastY = event.clientY;
|
|
284
|
+
},
|
|
285
|
+
[onHoverElement, panBy, targetUnder],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const endPress = useCallback(
|
|
289
|
+
(event: ReactPointerEvent<HTMLDivElement>, click: boolean) => {
|
|
290
|
+
const press = pressRef.current;
|
|
291
|
+
if (press === null || press.id !== event.pointerId) return;
|
|
292
|
+
pressRef.current = null;
|
|
293
|
+
// Released over a control (the press began on the canvas): not a click
|
|
294
|
+
// on the diagram, and `targetUnder` answering null would otherwise
|
|
295
|
+
// read as "comment on the whole diagram".
|
|
296
|
+
if (isDiagramControlEvent(event)) {
|
|
297
|
+
if (press.panning) setPanning(false);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (press.panning) {
|
|
301
|
+
setPanning(false);
|
|
302
|
+
const target = event.currentTarget;
|
|
303
|
+
if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(event.pointerId)) {
|
|
304
|
+
target.releasePointerCapture(event.pointerId);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (click) onClickElement(targetUnder(event), event.shiftKey);
|
|
309
|
+
},
|
|
310
|
+
[onClickElement, targetUnder],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const onKeyDown = useCallback(
|
|
314
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
315
|
+
// Keys act on the canvas itself, never on the composer's textarea.
|
|
316
|
+
if (event.target !== event.currentTarget) return;
|
|
317
|
+
// Mod+0, Mod+-, Alt+Arrow belong to the browser (page zoom, history).
|
|
318
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
319
|
+
switch (event.key) {
|
|
320
|
+
case '+':
|
|
321
|
+
case '=':
|
|
322
|
+
zoomBy(ZOOM_STEP);
|
|
323
|
+
break;
|
|
324
|
+
case '-':
|
|
325
|
+
case '_':
|
|
326
|
+
zoomBy(1 / ZOOM_STEP);
|
|
327
|
+
break;
|
|
328
|
+
case '0':
|
|
329
|
+
fit();
|
|
330
|
+
break;
|
|
331
|
+
case 'ArrowLeft':
|
|
332
|
+
panBy(event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX, 0);
|
|
333
|
+
break;
|
|
334
|
+
case 'ArrowRight':
|
|
335
|
+
panBy(event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX, 0);
|
|
336
|
+
break;
|
|
337
|
+
case 'ArrowUp':
|
|
338
|
+
panBy(0, event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX);
|
|
339
|
+
break;
|
|
340
|
+
case 'ArrowDown':
|
|
341
|
+
panBy(0, event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX);
|
|
342
|
+
break;
|
|
343
|
+
case 'Escape': {
|
|
344
|
+
if (onEscape() !== 'consumed') return;
|
|
345
|
+
event.stopPropagation();
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
},
|
|
353
|
+
[fit, onEscape, panBy, zoomBy],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const handle = useMemo<DiagramCanvasHandle>(() => ({ viewport, hostRef, panIntoView }), [panIntoView, viewport]);
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div
|
|
360
|
+
ref={hostRef}
|
|
361
|
+
data-diagram-canvas=""
|
|
362
|
+
tabIndex={0}
|
|
363
|
+
aria-label="Diagram canvas. Drag or arrow keys to pan, wheel or plus and minus to zoom, 0 to fit, click a part to comment."
|
|
364
|
+
className={cn(
|
|
365
|
+
'relative h-full w-full touch-pan-y select-none overflow-hidden outline-none',
|
|
366
|
+
panning ? 'cursor-grabbing' : 'cursor-grab',
|
|
367
|
+
className,
|
|
368
|
+
)}
|
|
369
|
+
onPointerDown={onPointerDown}
|
|
370
|
+
onPointerMove={onPointerMove}
|
|
371
|
+
onPointerUp={(event) => endPress(event, true)}
|
|
372
|
+
onPointerCancel={(event) => endPress(event, false)}
|
|
373
|
+
onPointerLeave={() => onHoverElement(null)}
|
|
374
|
+
onKeyDown={onKeyDown}
|
|
375
|
+
>
|
|
376
|
+
<div
|
|
377
|
+
ref={wrapperRef}
|
|
378
|
+
data-diagram-svg=""
|
|
379
|
+
className={cn('absolute left-0 top-0 origin-top-left transition-opacity duration-150', dimmed && 'opacity-40')}
|
|
380
|
+
style={{
|
|
381
|
+
width: content?.width,
|
|
382
|
+
height: content?.height,
|
|
383
|
+
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
|
384
|
+
}}
|
|
385
|
+
/>
|
|
386
|
+
{overlay(handle)}
|
|
387
|
+
{children}
|
|
388
|
+
<div
|
|
389
|
+
// Screen chrome: never printed (rings and badges are, they are the
|
|
390
|
+
// comments). On a narrow screen the strip takes the left edge so it
|
|
391
|
+
// never stacks under a host's own bottom-right controls.
|
|
392
|
+
data-print-hide=""
|
|
393
|
+
data-diagram-control=""
|
|
394
|
+
data-diagram-zoom-strip=""
|
|
395
|
+
className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-card/85 p-0.5 backdrop-blur max-md:bottom-4 max-md:left-4 max-md:right-auto"
|
|
396
|
+
role="group"
|
|
397
|
+
aria-label="Zoom"
|
|
398
|
+
>
|
|
399
|
+
<Button
|
|
400
|
+
type="button"
|
|
401
|
+
variant="ghost"
|
|
402
|
+
size="icon"
|
|
403
|
+
className="size-7"
|
|
404
|
+
aria-label="Zoom out"
|
|
405
|
+
title="Zoom out (-)"
|
|
406
|
+
onClick={() => zoomBy(1 / ZOOM_STEP)}
|
|
407
|
+
>
|
|
408
|
+
<Minus aria-hidden="true" />
|
|
409
|
+
</Button>
|
|
410
|
+
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Fit diagram" title="Fit (0)" onClick={fit}>
|
|
411
|
+
<Maximize2 aria-hidden="true" />
|
|
412
|
+
</Button>
|
|
413
|
+
<Button
|
|
414
|
+
type="button"
|
|
415
|
+
variant="ghost"
|
|
416
|
+
size="icon"
|
|
417
|
+
className="size-7"
|
|
418
|
+
aria-label="Zoom in"
|
|
419
|
+
title="Zoom in (+)"
|
|
420
|
+
onClick={() => zoomBy(ZOOM_STEP)}
|
|
421
|
+
>
|
|
422
|
+
<Plus aria-hidden="true" />
|
|
423
|
+
</Button>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
2
|
+
import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
import type { ScreenRect } from '../../utils/diagram-projection';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import type { DiagramComposerDraft } from './useDiagramComments';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The inline composer beside the ring: the part's label as the context
|
|
10
|
+
* line, a textarea, Cancel and Comment. Enter saves, Shift+Enter breaks a
|
|
11
|
+
* line and Esc discards, and none of the three is spelled on screen (owner
|
|
12
|
+
* ruling; the markdown composer shows no hint either). It lives here
|
|
13
|
+
* because the markdown composer is bound to web-highlighter's selection and
|
|
14
|
+
* the html composer to the sandbox bridge; neither reaches an app-owned svg.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The composer's width; it sits to the right of the ring and flips left
|
|
18
|
+
* when the host is too narrow there. */
|
|
19
|
+
const COMPOSER_WIDTH_PX = 288;
|
|
20
|
+
|
|
21
|
+
export function DiagramComposer({
|
|
22
|
+
draft,
|
|
23
|
+
anchorRect,
|
|
24
|
+
hostWidth,
|
|
25
|
+
sourceDirty,
|
|
26
|
+
submitting,
|
|
27
|
+
error,
|
|
28
|
+
disabledReason,
|
|
29
|
+
onSubmit,
|
|
30
|
+
onCancel,
|
|
31
|
+
}: {
|
|
32
|
+
draft: DiagramComposerDraft;
|
|
33
|
+
anchorRect: ScreenRect;
|
|
34
|
+
hostWidth: number;
|
|
35
|
+
sourceDirty: boolean;
|
|
36
|
+
submitting: boolean;
|
|
37
|
+
/** The last submit's failure, shown under the textarea. */
|
|
38
|
+
error: string | null;
|
|
39
|
+
/** When set, commenting is off for this viewer and the composer says
|
|
40
|
+
* why instead of offering a textarea. */
|
|
41
|
+
disabledReason?: string;
|
|
42
|
+
onSubmit: (body: string) => void;
|
|
43
|
+
onCancel: () => void;
|
|
44
|
+
}) {
|
|
45
|
+
const [text, setText] = useState('');
|
|
46
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
textareaRef.current?.focus();
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const target = draft.primary.target;
|
|
52
|
+
const unsavedPart = draft.sourceLine === null && sourceDirty;
|
|
53
|
+
const canWrite = disabledReason === undefined && !unsavedPart;
|
|
54
|
+
const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
|
|
55
|
+
const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
|
|
56
|
+
|
|
57
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
58
|
+
if (event.key === 'Escape') {
|
|
59
|
+
// Ours: the canvas and whatever holds it (a popout) must not also
|
|
60
|
+
// act on this Escape.
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
onCancel();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
if (canWrite && text.trim() !== '') onSubmit(text);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-diagram-composer=""
|
|
75
|
+
role="dialog"
|
|
76
|
+
aria-label={`Comment on ${diagramTargetText(target)}`}
|
|
77
|
+
className="pointer-events-auto absolute z-10 flex w-72 flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
|
|
78
|
+
style={{ left, top: Math.max(0, anchorRect.top) }}
|
|
79
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
80
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
81
|
+
onKeyDown={(event) => {
|
|
82
|
+
// Keys inside the composer never reach the canvas's zoom keys.
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<div className="text-[10px] text-muted-foreground">
|
|
87
|
+
On <span className="font-medium text-foreground">{diagramTargetText(target)}</span> ({diagramTargetName(target)})
|
|
88
|
+
{draft.sourceLine !== null && ` · line ${draft.sourceLine[0]}`}
|
|
89
|
+
{draft.additional.length > 0 && ` · +${draft.additional.length} more`}
|
|
90
|
+
</div>
|
|
91
|
+
{disabledReason === undefined ? (
|
|
92
|
+
<>
|
|
93
|
+
{unsavedPart && (
|
|
94
|
+
<p className="text-[10px] text-warning">This part is only in your unsaved draft. Save the diagram to comment on it.</p>
|
|
95
|
+
)}
|
|
96
|
+
<textarea
|
|
97
|
+
ref={textareaRef}
|
|
98
|
+
value={text}
|
|
99
|
+
onChange={(event) => setText(event.target.value)}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
placeholder="Add a comment..."
|
|
102
|
+
rows={3}
|
|
103
|
+
disabled={submitting}
|
|
104
|
+
className={cn(
|
|
105
|
+
'w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground',
|
|
106
|
+
'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
{error !== null && (
|
|
110
|
+
<p role="alert" className="text-[10px] text-destructive">
|
|
111
|
+
{error}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
<div className="flex items-center justify-end gap-1">
|
|
115
|
+
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
|
|
116
|
+
Cancel
|
|
117
|
+
</Button>
|
|
118
|
+
<Button type="button" size="xs" disabled={!canWrite || submitting || text.trim() === ''} onClick={() => onSubmit(text)}>
|
|
119
|
+
Comment
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
</>
|
|
123
|
+
) : (
|
|
124
|
+
<>
|
|
125
|
+
<p className="text-xs text-muted-foreground">{disabledReason}</p>
|
|
126
|
+
<div className="flex justify-end">
|
|
127
|
+
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
|
|
128
|
+
Close
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|