@plannotator/ui 0.40.0 → 0.41.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 +46 -5
- package/README.md +6 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/MermaidBlock.tsx +17 -664
- package/components/Viewer.tsx +42 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -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
|
@@ -40,6 +40,7 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
|
|
|
40
40
|
import { TaterSpriteSitting } from './TaterSpriteSitting';
|
|
41
41
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
42
42
|
import { MessagesIcon } from './icons/MessagesIcon';
|
|
43
|
+
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
43
44
|
import { GraphvizBlock } from './GraphvizBlock';
|
|
44
45
|
import { MermaidBlock } from './MermaidBlock';
|
|
45
46
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
@@ -1042,8 +1043,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1042
1043
|
</>
|
|
1043
1044
|
);
|
|
1044
1045
|
|
|
1046
|
+
// The document's diagram blocks, in order: a diagram comment that names
|
|
1047
|
+
// none of them (an external POST, a deleted fence) is resolved by anchor
|
|
1048
|
+
// against each, first resolver wins (see diagram/anchorClaims).
|
|
1049
|
+
const diagramBlockKey = blocks
|
|
1050
|
+
.filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
|
|
1051
|
+
.map((b) => b.id)
|
|
1052
|
+
.join('\n');
|
|
1053
|
+
const diagramClaims = useMemo(
|
|
1054
|
+
() => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
|
|
1055
|
+
[diagramBlockKey],
|
|
1056
|
+
);
|
|
1057
|
+
// With no diagram in the document nobody can resolve a diagram comment:
|
|
1058
|
+
// it is unanchored, and the highlighter (which skips it) will not say so.
|
|
1059
|
+
useEffect(() => {
|
|
1060
|
+
if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
|
|
1061
|
+
const ids = annotations.filter((ann) => ann.diagramAnchor !== undefined).map((ann) => ann.id);
|
|
1062
|
+
if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
|
|
1063
|
+
}, [annotations, diagramBlockKey, onRestoreReport]);
|
|
1064
|
+
|
|
1045
1065
|
return (
|
|
1046
1066
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
1067
|
+
<DiagramAnchorClaimsContext.Provider value={diagramClaims}>
|
|
1047
1068
|
<div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
|
|
1048
1069
|
{taterMode && <TaterSpriteSitting />}
|
|
1049
1070
|
<article
|
|
@@ -1147,9 +1168,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1147
1168
|
);
|
|
1148
1169
|
})()
|
|
1149
1170
|
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
|
|
1150
|
-
<MermaidBlock
|
|
1171
|
+
<MermaidBlock
|
|
1172
|
+
key={group.block.id}
|
|
1173
|
+
block={group.block}
|
|
1174
|
+
annotations={annotations}
|
|
1175
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1176
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1177
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1178
|
+
readOnly={readOnly}
|
|
1179
|
+
onRestoreReport={onRestoreReport}
|
|
1180
|
+
/>
|
|
1151
1181
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
1152
|
-
<GraphvizBlock
|
|
1182
|
+
<GraphvizBlock
|
|
1183
|
+
key={group.block.id}
|
|
1184
|
+
block={group.block}
|
|
1185
|
+
annotations={annotations}
|
|
1186
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1187
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1188
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1189
|
+
readOnly={readOnly}
|
|
1190
|
+
onRestoreReport={onRestoreReport}
|
|
1191
|
+
/>
|
|
1153
1192
|
) : group.block.type === 'table' ? (
|
|
1154
1193
|
<TableBlock
|
|
1155
1194
|
key={group.block.id}
|
|
@@ -1444,6 +1483,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1444
1483
|
document.body
|
|
1445
1484
|
)}
|
|
1446
1485
|
</div>
|
|
1486
|
+
</DiagramAnchorClaimsContext.Provider>
|
|
1447
1487
|
</CodePathValidationContext.Provider>
|
|
1448
1488
|
);
|
|
1449
1489
|
});
|
|
@@ -0,0 +1,431 @@
|
|
|
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 {
|
|
18
|
+
DRAG_THRESHOLD_PX,
|
|
19
|
+
TOUCH_DRAG_THRESHOLD_PX,
|
|
20
|
+
useDiagramViewport,
|
|
21
|
+
WHEEL_ZOOM_SENSITIVITY,
|
|
22
|
+
ZOOM_STEP,
|
|
23
|
+
type ContentSize,
|
|
24
|
+
type Viewport,
|
|
25
|
+
} from './useDiagramViewport';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The canvas: the rendered svg inside a transformed wrapper, edge to edge in
|
|
29
|
+
* its host. Wheel and pinch zoom about the pointer, drag pans, `+` `-` `0`
|
|
30
|
+
* (fit) and the arrow keys on the keyboard, a small control in the corner.
|
|
31
|
+
* Click-to-select, drag-to-pan: a press that does not travel the drag
|
|
32
|
+
* threshold (4 px for a mouse or pen, 10 px for a finger) is a click and
|
|
33
|
+
* opens the composer; one that travels is a pan and never opens it. Nothing
|
|
34
|
+
* highlights on a plain mouse-over (the owner removed hover targeting: it
|
|
35
|
+
* read as messy and fought the pan hand); the only pre-click affordance is
|
|
36
|
+
* the ring under the pointer while the platform modifier is held, and it
|
|
37
|
+
* disarms on the key's release, on any other key, and on window blur.
|
|
38
|
+
*
|
|
39
|
+
* What a click means is decided over EVERYTHING under the pointer
|
|
40
|
+
* (`elementsFromPoint`), never the topmost element alone: every edge has an
|
|
41
|
+
* invisible 14 px hit path in a layer above the diagram (the render slot's
|
|
42
|
+
* `widenEdgeHitAreas`), so where an edge meets a node both are under the
|
|
43
|
+
* pointer, and the caller's `pickTarget` takes the node first, then an edge,
|
|
44
|
+
* then a cluster.
|
|
45
|
+
*
|
|
46
|
+
* In the document flow the canvas lets a finger scroll the page vertically
|
|
47
|
+
* (`touch-action: pan-y`); a full-size host that owns the screen passes
|
|
48
|
+
* `touch-none` through `className`. Under strict Mermaid disables click
|
|
49
|
+
* callbacks, and a `click A "https://..."` link binding still renders an
|
|
50
|
+
* `<a href>` that the render slot's sanitizer strips, so the canvas owns
|
|
51
|
+
* every click and there is no armed switch.
|
|
52
|
+
*
|
|
53
|
+
* The render slot hands over a sanitized svg NODE, not markup: the wrapper
|
|
54
|
+
* mounts it with `replaceChildren` once per render, so no html string ever
|
|
55
|
+
* crosses into the app DOM here. The overlay (a sibling of the wrapper,
|
|
56
|
+
* unscaled) is the caller's, rendered through `overlay` with the live
|
|
57
|
+
* viewport so rings reproject on every change.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** A numeric svg length: `206`, `206pt`, `206px`. */
|
|
61
|
+
function svgLength(value: string | null): number {
|
|
62
|
+
if (value === null) return Number.NaN;
|
|
63
|
+
return Number.parseFloat(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
|
|
67
|
+
* write one), else its `width` / `height` attributes (a `pt` or `px` suffix
|
|
68
|
+
* is accepted, as Graphviz writes them). */
|
|
69
|
+
export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
|
|
70
|
+
const viewBox = svg.getAttribute('viewBox');
|
|
71
|
+
if (viewBox !== null) {
|
|
72
|
+
const parts = viewBox
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/[\s,]+/u)
|
|
75
|
+
.map(Number);
|
|
76
|
+
const width = parts[2];
|
|
77
|
+
const height = parts[3];
|
|
78
|
+
if (
|
|
79
|
+
parts.length === 4 &&
|
|
80
|
+
width !== undefined &&
|
|
81
|
+
height !== undefined &&
|
|
82
|
+
Number.isFinite(width) &&
|
|
83
|
+
Number.isFinite(height) &&
|
|
84
|
+
width > 0 &&
|
|
85
|
+
height > 0
|
|
86
|
+
) {
|
|
87
|
+
return { width, height };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const width = svgLength(svg.getAttribute('width'));
|
|
91
|
+
const height = svgLength(svg.getAttribute('height'));
|
|
92
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One arrow-key press pans this far (the diagram moves WITH the arrow, as
|
|
96
|
+
* a scroll would); Shift multiplies it by five. */
|
|
97
|
+
export const KEY_PAN_PX = 40;
|
|
98
|
+
|
|
99
|
+
export interface DiagramCanvasHandle {
|
|
100
|
+
readonly viewport: Viewport;
|
|
101
|
+
readonly hostRef: React.RefObject<HTMLDivElement | null>;
|
|
102
|
+
readonly panIntoView: ReturnType<typeof useDiagramViewport>['panIntoView'];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What an Escape on the canvas did: `consumed` closed something the viewer
|
|
106
|
+
* owns (a draft, a selection) and the key goes no further; `pass` lets it
|
|
107
|
+
* reach whatever holds the canvas (a popout's close). */
|
|
108
|
+
export type DiagramEscapeOutcome = 'consumed' | 'pass';
|
|
109
|
+
|
|
110
|
+
export function DiagramCanvas({
|
|
111
|
+
svgNode,
|
|
112
|
+
targetSelector,
|
|
113
|
+
pickTarget,
|
|
114
|
+
dimmed,
|
|
115
|
+
onSvgRoot,
|
|
116
|
+
onHoverElement,
|
|
117
|
+
onClickElement,
|
|
118
|
+
onEscape,
|
|
119
|
+
overlay,
|
|
120
|
+
children,
|
|
121
|
+
autoFocus,
|
|
122
|
+
className,
|
|
123
|
+
}: {
|
|
124
|
+
/** The last good render's sanitized svg root, or null before the first. */
|
|
125
|
+
svgNode: SVGSVGElement | null;
|
|
126
|
+
/** The engine's selector of every element the pointer can address
|
|
127
|
+
* (the finder's, through the renderer slot). */
|
|
128
|
+
targetSelector: string;
|
|
129
|
+
/** Choose among the addressable elements under the pointer, topmost
|
|
130
|
+
* first (the viewer's rule: a node, then an edge, then a cluster).
|
|
131
|
+
* Default: the topmost. */
|
|
132
|
+
pickTarget?: (candidates: readonly Element[]) => Element | null;
|
|
133
|
+
/** A parse error keeps the last render under the strip, dimmed. */
|
|
134
|
+
dimmed: boolean;
|
|
135
|
+
/** The mounted svg root after each injection (null on unmount). */
|
|
136
|
+
onSvgRoot: (root: SVGSVGElement | null) => void;
|
|
137
|
+
onHoverElement: (element: Element | null) => void;
|
|
138
|
+
onClickElement: (element: Element | null, shiftKey: boolean) => void;
|
|
139
|
+
onEscape: () => DiagramEscapeOutcome;
|
|
140
|
+
/** The overlay layer, given the live viewport and host. */
|
|
141
|
+
overlay: (handle: DiagramCanvasHandle) => ReactNode;
|
|
142
|
+
children?: ReactNode;
|
|
143
|
+
/** Take the keyboard on mount so `+`, `-`, `0` and Escape work at once
|
|
144
|
+
* (a popout). Off in the document flow, where it would steal the focus
|
|
145
|
+
* from the reader. */
|
|
146
|
+
autoFocus?: boolean;
|
|
147
|
+
/** `touch-none` for a host that owns the whole screen (a popout); the
|
|
148
|
+
* default lets a finger scroll the page past an inline diagram. */
|
|
149
|
+
className?: string;
|
|
150
|
+
}) {
|
|
151
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
152
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
153
|
+
const [content, setContent] = useState<ContentSize | null>(null);
|
|
154
|
+
const { viewport, fit, zoomBy, panBy, panIntoView } = useDiagramViewport(hostRef, content);
|
|
155
|
+
|
|
156
|
+
// Mount the sanitized node once per render and size the wrapper to the
|
|
157
|
+
// diagram's own box so the transform scales real pixels.
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
const wrapper = wrapperRef.current;
|
|
160
|
+
if (wrapper === null) return;
|
|
161
|
+
if (svgNode === null) {
|
|
162
|
+
wrapper.replaceChildren();
|
|
163
|
+
setContent(null);
|
|
164
|
+
onSvgRoot(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
wrapper.replaceChildren(svgNode);
|
|
168
|
+
const size = svgContentSize(svgNode);
|
|
169
|
+
if (size !== null) {
|
|
170
|
+
svgNode.style.width = '100%';
|
|
171
|
+
svgNode.style.height = '100%';
|
|
172
|
+
svgNode.style.maxWidth = 'none';
|
|
173
|
+
}
|
|
174
|
+
setContent(size);
|
|
175
|
+
onSvgRoot(svgNode);
|
|
176
|
+
return () => onSvgRoot(null);
|
|
177
|
+
}, [onSvgRoot, svgNode]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (autoFocus) hostRef.current?.focus();
|
|
181
|
+
}, [autoFocus]);
|
|
182
|
+
|
|
183
|
+
// Wheel zoom needs a non-passive listener (React's onWheel is passive,
|
|
184
|
+
// so preventDefault there cannot stop the page from scrolling).
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const host = hostRef.current;
|
|
187
|
+
if (host === null) return;
|
|
188
|
+
const onWheel = (event: WheelEvent) => {
|
|
189
|
+
// A sub-pixel delta (a trackpad coming to rest, a horizontal swipe)
|
|
190
|
+
// is neither a zoom nor a reason to hold the page's scroll.
|
|
191
|
+
if (Math.abs(event.deltaY) < 0.1) return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), event.clientX, event.clientY);
|
|
194
|
+
};
|
|
195
|
+
host.addEventListener('wheel', onWheel, { passive: false });
|
|
196
|
+
return () => host.removeEventListener('wheel', onWheel);
|
|
197
|
+
}, [zoomBy]);
|
|
198
|
+
|
|
199
|
+
const targetUnder = useCallback(
|
|
200
|
+
(event: ReactPointerEvent): Element | null => {
|
|
201
|
+
const wrapper = wrapperRef.current;
|
|
202
|
+
if (wrapper === null) return null;
|
|
203
|
+
// Everything under the pointer, topmost first. Without a layout
|
|
204
|
+
// engine (happy-dom) the event's own target is all there is.
|
|
205
|
+
const doc = wrapper.ownerDocument;
|
|
206
|
+
const stack =
|
|
207
|
+
typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
|
|
208
|
+
const under = stack.length > 0 ? stack : event.target instanceof Element ? [event.target] : [];
|
|
209
|
+
const candidates: Element[] = [];
|
|
210
|
+
for (const el of under) {
|
|
211
|
+
if (!wrapper.contains(el)) continue;
|
|
212
|
+
// A hit path stands for the visible edge it was made from.
|
|
213
|
+
const node = diagramHitSource(el) ?? el;
|
|
214
|
+
const target = node.closest(targetSelector);
|
|
215
|
+
if (target !== null && wrapper.contains(target) && !candidates.includes(target)) candidates.push(target);
|
|
216
|
+
}
|
|
217
|
+
if (candidates.length === 0) return null;
|
|
218
|
+
return pickTarget ? pickTarget(candidates) : (candidates[0] ?? null);
|
|
219
|
+
},
|
|
220
|
+
[pickTarget, targetSelector],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// The modifier-gated ring disarms the way the token hover cards do: on
|
|
224
|
+
// the modifier's release, on any other key while it is held (Cmd+C is a
|
|
225
|
+
// copy, not a question about the part under the pointer), and on blur.
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const win = hostRef.current?.ownerDocument.defaultView;
|
|
228
|
+
if (!win) return;
|
|
229
|
+
const modKey = isMac ? 'Meta' : 'Control';
|
|
230
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
231
|
+
if (event.key === modKey) onHoverElement(null);
|
|
232
|
+
};
|
|
233
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
234
|
+
if (event.key !== modKey) onHoverElement(null);
|
|
235
|
+
};
|
|
236
|
+
const onBlur = () => onHoverElement(null);
|
|
237
|
+
win.addEventListener('keyup', onKeyUp);
|
|
238
|
+
win.addEventListener('keydown', onKeyDown);
|
|
239
|
+
win.addEventListener('blur', onBlur);
|
|
240
|
+
return () => {
|
|
241
|
+
win.removeEventListener('keyup', onKeyUp);
|
|
242
|
+
win.removeEventListener('keydown', onKeyDown);
|
|
243
|
+
win.removeEventListener('blur', onBlur);
|
|
244
|
+
};
|
|
245
|
+
}, [onHoverElement]);
|
|
246
|
+
|
|
247
|
+
// Press bookkeeping: where the pointer went down and whether it became a
|
|
248
|
+
// pan. Pointer capture keeps the pan alive past the host's edge.
|
|
249
|
+
const pressRef = useRef<{
|
|
250
|
+
id: number;
|
|
251
|
+
x: number;
|
|
252
|
+
y: number;
|
|
253
|
+
lastX: number;
|
|
254
|
+
lastY: number;
|
|
255
|
+
threshold: number;
|
|
256
|
+
panning: boolean;
|
|
257
|
+
} | null>(null);
|
|
258
|
+
const [panning, setPanning] = useState(false);
|
|
259
|
+
|
|
260
|
+
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
261
|
+
if (event.button !== 0) return;
|
|
262
|
+
pressRef.current = {
|
|
263
|
+
id: event.pointerId,
|
|
264
|
+
x: event.clientX,
|
|
265
|
+
y: event.clientY,
|
|
266
|
+
lastX: event.clientX,
|
|
267
|
+
lastY: event.clientY,
|
|
268
|
+
// A fingertip wobbles more than a mouse: a tap must still be a tap.
|
|
269
|
+
threshold: event.pointerType === 'touch' ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX,
|
|
270
|
+
panning: false,
|
|
271
|
+
};
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
const onPointerMove = useCallback(
|
|
275
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
276
|
+
const press = pressRef.current;
|
|
277
|
+
if (press === null || press.id !== event.pointerId) {
|
|
278
|
+
// Nothing highlights on a plain mouse-over; the ring under the
|
|
279
|
+
// pointer exists only under the platform modifier.
|
|
280
|
+
onHoverElement(isModKeyHeld(event) ? targetUnder(event) : null);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!press.panning) {
|
|
284
|
+
const travelled = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
|
285
|
+
if (travelled < press.threshold) return;
|
|
286
|
+
press.panning = true;
|
|
287
|
+
setPanning(true);
|
|
288
|
+
onHoverElement(null);
|
|
289
|
+
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
|
290
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
panBy(event.clientX - press.lastX, event.clientY - press.lastY);
|
|
294
|
+
press.lastX = event.clientX;
|
|
295
|
+
press.lastY = event.clientY;
|
|
296
|
+
},
|
|
297
|
+
[onHoverElement, panBy, targetUnder],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const endPress = useCallback(
|
|
301
|
+
(event: ReactPointerEvent<HTMLDivElement>, click: boolean) => {
|
|
302
|
+
const press = pressRef.current;
|
|
303
|
+
if (press === null || press.id !== event.pointerId) return;
|
|
304
|
+
pressRef.current = null;
|
|
305
|
+
if (press.panning) {
|
|
306
|
+
setPanning(false);
|
|
307
|
+
const target = event.currentTarget;
|
|
308
|
+
if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(event.pointerId)) {
|
|
309
|
+
target.releasePointerCapture(event.pointerId);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (click) onClickElement(targetUnder(event), event.shiftKey);
|
|
314
|
+
},
|
|
315
|
+
[onClickElement, targetUnder],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const onKeyDown = useCallback(
|
|
319
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
320
|
+
// Keys act on the canvas itself, never on the composer's textarea.
|
|
321
|
+
if (event.target !== event.currentTarget) return;
|
|
322
|
+
// Mod+0, Mod+-, Alt+Arrow belong to the browser (page zoom, history).
|
|
323
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
324
|
+
switch (event.key) {
|
|
325
|
+
case '+':
|
|
326
|
+
case '=':
|
|
327
|
+
zoomBy(ZOOM_STEP);
|
|
328
|
+
break;
|
|
329
|
+
case '-':
|
|
330
|
+
case '_':
|
|
331
|
+
zoomBy(1 / ZOOM_STEP);
|
|
332
|
+
break;
|
|
333
|
+
case '0':
|
|
334
|
+
fit();
|
|
335
|
+
break;
|
|
336
|
+
case 'ArrowLeft':
|
|
337
|
+
panBy(event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX, 0);
|
|
338
|
+
break;
|
|
339
|
+
case 'ArrowRight':
|
|
340
|
+
panBy(event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX, 0);
|
|
341
|
+
break;
|
|
342
|
+
case 'ArrowUp':
|
|
343
|
+
panBy(0, event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX);
|
|
344
|
+
break;
|
|
345
|
+
case 'ArrowDown':
|
|
346
|
+
panBy(0, event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX);
|
|
347
|
+
break;
|
|
348
|
+
case 'Escape': {
|
|
349
|
+
if (onEscape() !== 'consumed') return;
|
|
350
|
+
event.stopPropagation();
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
default:
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
},
|
|
358
|
+
[fit, onEscape, panBy, zoomBy],
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const handle = useMemo<DiagramCanvasHandle>(() => ({ viewport, hostRef, panIntoView }), [panIntoView, viewport]);
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<div
|
|
365
|
+
ref={hostRef}
|
|
366
|
+
data-diagram-canvas=""
|
|
367
|
+
tabIndex={0}
|
|
368
|
+
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."
|
|
369
|
+
className={cn(
|
|
370
|
+
'relative h-full w-full touch-pan-y select-none overflow-hidden outline-none',
|
|
371
|
+
panning ? 'cursor-grabbing' : 'cursor-grab',
|
|
372
|
+
className,
|
|
373
|
+
)}
|
|
374
|
+
onPointerDown={onPointerDown}
|
|
375
|
+
onPointerMove={onPointerMove}
|
|
376
|
+
onPointerUp={(event) => endPress(event, true)}
|
|
377
|
+
onPointerCancel={(event) => endPress(event, false)}
|
|
378
|
+
onPointerLeave={() => onHoverElement(null)}
|
|
379
|
+
onKeyDown={onKeyDown}
|
|
380
|
+
>
|
|
381
|
+
<div
|
|
382
|
+
ref={wrapperRef}
|
|
383
|
+
data-diagram-svg=""
|
|
384
|
+
className={cn('absolute left-0 top-0 origin-top-left transition-opacity duration-150', dimmed && 'opacity-40')}
|
|
385
|
+
style={{
|
|
386
|
+
width: content?.width,
|
|
387
|
+
height: content?.height,
|
|
388
|
+
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
|
389
|
+
}}
|
|
390
|
+
/>
|
|
391
|
+
{overlay(handle)}
|
|
392
|
+
{children}
|
|
393
|
+
<div
|
|
394
|
+
// Screen chrome: never printed (rings and badges are, they are the
|
|
395
|
+
// comments). On a narrow screen the strip takes the left edge so it
|
|
396
|
+
// never stacks under a host's own bottom-right controls.
|
|
397
|
+
data-print-hide=""
|
|
398
|
+
data-diagram-zoom-strip=""
|
|
399
|
+
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"
|
|
400
|
+
role="group"
|
|
401
|
+
aria-label="Zoom"
|
|
402
|
+
>
|
|
403
|
+
<Button
|
|
404
|
+
type="button"
|
|
405
|
+
variant="ghost"
|
|
406
|
+
size="icon"
|
|
407
|
+
className="size-7"
|
|
408
|
+
aria-label="Zoom out"
|
|
409
|
+
title="Zoom out (-)"
|
|
410
|
+
onClick={() => zoomBy(1 / ZOOM_STEP)}
|
|
411
|
+
>
|
|
412
|
+
<Minus aria-hidden="true" />
|
|
413
|
+
</Button>
|
|
414
|
+
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Fit diagram" title="Fit (0)" onClick={fit}>
|
|
415
|
+
<Maximize2 aria-hidden="true" />
|
|
416
|
+
</Button>
|
|
417
|
+
<Button
|
|
418
|
+
type="button"
|
|
419
|
+
variant="ghost"
|
|
420
|
+
size="icon"
|
|
421
|
+
className="size-7"
|
|
422
|
+
aria-label="Zoom in"
|
|
423
|
+
title="Zoom in (+)"
|
|
424
|
+
onClick={() => zoomBy(ZOOM_STEP)}
|
|
425
|
+
>
|
|
426
|
+
<Plus aria-hidden="true" />
|
|
427
|
+
</Button>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
@@ -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
|
+
}
|