@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.
@@ -0,0 +1,215 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { diagramTargetName } from '@plannotator/core/diagram-anchor';
3
+ import { cn } from '../../lib/utils';
4
+ import { projectElement, type ScreenRect } from '../../utils/diagram-projection';
5
+ import type { DiagramCanvasHandle } from './DiagramCanvas';
6
+ import type { DiagramComposerDraft, DiagramHover, ResolvedDiagramComment } from './useDiagramComments';
7
+
8
+ /**
9
+ * Rings, numbered badges, the hover chip and the composer's anchor point,
10
+ * projected from each part's `getBBox()` through `getScreenCTM()`. The
11
+ * layer is a sibling of the transformed wrapper, so a ring is the same 2px
12
+ * at every zoom. Reprojection is one requestAnimationFrame pass per change
13
+ * (viewport, host resize, a new render, a new comment list): a design
14
+ * constant, not a limit.
15
+ */
16
+
17
+ /** Ring inset from the part's box, in overlay pixels. */
18
+ const RING_PAD_PX = 3;
19
+
20
+ interface Projected {
21
+ readonly id: string;
22
+ readonly number: number;
23
+ readonly label: string;
24
+ readonly resolved: boolean;
25
+ readonly whole: boolean;
26
+ readonly rect: ScreenRect;
27
+ readonly additional: readonly ScreenRect[];
28
+ }
29
+
30
+ function pad(rect: ScreenRect): ScreenRect {
31
+ return {
32
+ left: rect.left - RING_PAD_PX,
33
+ top: rect.top - RING_PAD_PX,
34
+ width: rect.width + RING_PAD_PX * 2,
35
+ height: rect.height + RING_PAD_PX * 2,
36
+ };
37
+ }
38
+
39
+ function ringStyle(rect: ScreenRect): React.CSSProperties {
40
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
41
+ }
42
+
43
+ export function DiagramOverlay({
44
+ handle,
45
+ resolved,
46
+ hover,
47
+ composer,
48
+ selectedCommentId,
49
+ onSelectComment,
50
+ renderComposer,
51
+ }: {
52
+ handle: DiagramCanvasHandle;
53
+ resolved: readonly ResolvedDiagramComment[];
54
+ hover: DiagramHover | null;
55
+ composer: DiagramComposerDraft | null;
56
+ selectedCommentId: string | null;
57
+ onSelectComment: ((id: string | null) => void) | undefined;
58
+ /** The composer, given the primary ring's rectangle to sit beside. */
59
+ renderComposer: (anchorRect: ScreenRect) => React.ReactNode;
60
+ }) {
61
+ const [projected, setProjected] = useState<readonly Projected[]>([]);
62
+ const [hoverRect, setHoverRect] = useState<ScreenRect | null>(null);
63
+ const [draftRects, setDraftRects] = useState<{
64
+ primary: ScreenRect;
65
+ additional: readonly ScreenRect[];
66
+ } | null>(null);
67
+ const frameRef = useRef<number | null>(null);
68
+ const { viewport, hostRef, panIntoView } = handle;
69
+
70
+ // One projection pass per change, after the wrapper's transform has
71
+ // been written to the DOM (layout effect), on the next frame.
72
+ useLayoutEffect(() => {
73
+ const run = () => {
74
+ frameRef.current = null;
75
+ const host = hostRef.current;
76
+ if (host === null) return;
77
+ const hostRect = host.getBoundingClientRect();
78
+ const next: Projected[] = [];
79
+ for (const entry of resolved) {
80
+ if (entry.element === null) continue;
81
+ const rect = projectElement(entry.element, hostRect);
82
+ if (rect === null) continue;
83
+ next.push({
84
+ id: entry.id,
85
+ number: entry.number,
86
+ label: entry.label,
87
+ resolved: entry.resolved,
88
+ whole: entry.whole,
89
+ rect: pad(rect),
90
+ additional: entry.additional
91
+ .map((el) => projectElement(el, hostRect))
92
+ .filter((r): r is ScreenRect => r !== null)
93
+ .map(pad),
94
+ });
95
+ }
96
+ setProjected(next);
97
+ const hovered = hover === null ? null : projectElement(hover.element, hostRect);
98
+ setHoverRect(hovered === null ? null : pad(hovered));
99
+ if (composer === null) {
100
+ setDraftRects(null);
101
+ } else {
102
+ const primary = projectElement(composer.primary.element, hostRect);
103
+ setDraftRects(
104
+ primary === null
105
+ ? null
106
+ : {
107
+ primary: pad(primary),
108
+ additional: composer.additional
109
+ .map((extra) => projectElement(extra.element, hostRect))
110
+ .filter((r): r is ScreenRect => r !== null)
111
+ .map(pad),
112
+ },
113
+ );
114
+ }
115
+ };
116
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
117
+ if (typeof requestAnimationFrame === 'function') {
118
+ frameRef.current = requestAnimationFrame(run);
119
+ } else {
120
+ run();
121
+ }
122
+ return () => {
123
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
124
+ frameRef.current = null;
125
+ };
126
+ }, [composer, hostRef, hover, resolved, viewport]);
127
+
128
+ // Selecting a comment in the panel pans its part into view; the ring
129
+ // pulses through the selected class below.
130
+ const selectedRect = projected.find((entry) => entry.id === selectedCommentId)?.rect;
131
+ const pannedForRef = useRef<string | null>(null);
132
+ useEffect(() => {
133
+ if (selectedRect === undefined || selectedCommentId === null) return;
134
+ if (pannedForRef.current === selectedCommentId) return;
135
+ pannedForRef.current = selectedCommentId;
136
+ panIntoView(selectedRect);
137
+ }, [panIntoView, selectedCommentId, selectedRect]);
138
+ useEffect(() => {
139
+ if (selectedCommentId === null) pannedForRef.current = null;
140
+ }, [selectedCommentId]);
141
+
142
+ return (
143
+ <div data-diagram-overlay="" className="pointer-events-none absolute inset-0 z-[1]">
144
+ {projected.map((entry) => (
145
+ <div key={entry.id} data-diagram-mark={entry.id} data-number={entry.number}>
146
+ <div
147
+ className={cn(
148
+ 'absolute rounded-sm border-2',
149
+ entry.resolved ? 'border-muted-foreground/50' : 'border-primary/70',
150
+ entry.id === selectedCommentId && 'animate-pulse border-primary',
151
+ )}
152
+ style={ringStyle(entry.rect)}
153
+ aria-hidden="true"
154
+ />
155
+ {entry.additional.map((rect, index) => (
156
+ <div
157
+ key={index}
158
+ className="absolute rounded-sm border-2 border-dashed border-primary/60"
159
+ style={ringStyle(rect)}
160
+ aria-hidden="true"
161
+ />
162
+ ))}
163
+ <button
164
+ type="button"
165
+ data-diagram-badge={entry.id}
166
+ className={cn(
167
+ 'pointer-events-auto absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-full border font-mono text-[10px] font-medium tabular-nums',
168
+ // A part's badge rides its top-right corner; the whole
169
+ // diagram's sits top-left, clear of the parts' badges.
170
+ entry.whole ? '-translate-x-1/2' : 'translate-x-1/2',
171
+ entry.resolved
172
+ ? 'border-border bg-muted text-muted-foreground'
173
+ : 'border-primary-foreground/40 bg-primary text-primary-foreground',
174
+ entry.id === selectedCommentId && 'ring-2 ring-primary/40',
175
+ )}
176
+ style={{ left: entry.whole ? entry.rect.left : entry.rect.left + entry.rect.width, top: entry.rect.top }}
177
+ aria-label={`Comment ${entry.number}: ${entry.label}`}
178
+ title={entry.label}
179
+ onClick={(event) => {
180
+ event.stopPropagation();
181
+ onSelectComment?.(entry.id);
182
+ }}
183
+ onPointerDown={(event) => event.stopPropagation()}
184
+ onPointerUp={(event) => event.stopPropagation()}
185
+ >
186
+ {entry.number}
187
+ </button>
188
+ </div>
189
+ ))}
190
+ {hoverRect !== null && hover !== null && composer === null && (
191
+ <div data-diagram-hover="">
192
+ <div className="absolute rounded-sm border-2 border-primary/50" style={ringStyle(hoverRect)} aria-hidden="true" />
193
+ <span
194
+ className="absolute -translate-y-full whitespace-nowrap rounded-sm border border-border bg-card px-1.5 py-0.5 text-[10px] text-muted-foreground"
195
+ style={{ left: hoverRect.left, top: hoverRect.top - 4 }}
196
+ >
197
+ {hover.target.label !== '' ? hover.target.label : diagramTargetName(hover.target)}
198
+ {hover.target.label !== '' && (
199
+ <span className="text-muted-foreground/70"> · {diagramTargetName(hover.target)}</span>
200
+ )}
201
+ </span>
202
+ </div>
203
+ )}
204
+ {draftRects !== null && composer !== null && (
205
+ <div data-diagram-draft="">
206
+ <div className="absolute rounded-sm border-2 border-primary" style={ringStyle(draftRects.primary)} aria-hidden="true" />
207
+ {draftRects.additional.map((rect, index) => (
208
+ <div key={index} className="absolute rounded-sm border-2 border-dashed border-primary" style={ringStyle(rect)} aria-hidden="true" />
209
+ ))}
210
+ {renderComposer(draftRects.primary)}
211
+ </div>
212
+ )}
213
+ </div>
214
+ );
215
+ }
@@ -0,0 +1,70 @@
1
+ import { Code } from 'lucide-react';
2
+ import { useState } from 'react';
3
+ import { cn } from '../../lib/utils';
4
+ import { PopoutDialog } from '../PopoutDialog';
5
+ import { Button } from '../ui/button';
6
+ import { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
7
+
8
+ /**
9
+ * The diagram at full size over the document: the SAME `DiagramViewer` the
10
+ * fence renders inline, in the `TablePopout` chrome (a non-modal Base UI
11
+ * dialog with the dark scrim; annotation toolbars that portal outside it
12
+ * stay interactive). One code path: everything the popout can do, the
13
+ * inline canvas can do, only larger. The canvas takes the keyboard on
14
+ * arrival so `+`, `-`, `0` and Escape work without a click; Escape walks
15
+ * the viewer's ladder first (a draft, a selection) and closes the popout
16
+ * only when nothing else is left to close.
17
+ */
18
+ export function DiagramPopout({
19
+ open,
20
+ onClose,
21
+ title,
22
+ container,
23
+ dataAttributes,
24
+ ...viewer
25
+ }: Omit<DiagramViewerProps, 'onDismiss' | 'autoFocus' | 'sourceOpen' | 'className' | 'canvasClassName'> & {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ /** The dialog's accessible name and the header label. */
29
+ title: string;
30
+ /** Portal target; null falls back to body. */
31
+ container?: HTMLElement | null;
32
+ dataAttributes?: Record<string, string>;
33
+ }) {
34
+ const [sourceOpen, setSourceOpen] = useState(false);
35
+ const hasPane = viewer.onSave !== undefined;
36
+ return (
37
+ <PopoutDialog
38
+ open={open}
39
+ onClose={onClose}
40
+ title={title}
41
+ container={container}
42
+ className="h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none"
43
+ dataAttributes={{ 'data-diagram-popout': '', ...dataAttributes }}
44
+ >
45
+ <div className="flex h-9 shrink-0 items-center gap-2 border-b border-border pl-4 pr-12">
46
+ <span className="truncate text-xs font-medium text-foreground">{title}</span>
47
+ <span className="flex-1" />
48
+ {hasPane && (
49
+ <Button
50
+ type="button"
51
+ variant="ghost"
52
+ size="xs"
53
+ data-diagram-source-toggle=""
54
+ aria-pressed={sourceOpen}
55
+ className={cn('shrink-0', sourceOpen && 'bg-primary/15 text-primary')}
56
+ title={sourceOpen ? 'Close the source pane' : 'Open the diagram source beside the canvas'}
57
+ onClick={() => setSourceOpen((value) => !value)}
58
+ >
59
+ <Code className="size-3.5" aria-hidden="true" />
60
+ <span>Source</span>
61
+ </Button>
62
+ )}
63
+ </div>
64
+ <div className="min-h-0 flex-1">
65
+ {/* The popout owns the screen: every touch drag is a pan. */}
66
+ <DiagramViewer {...viewer} sourceOpen={sourceOpen} onDismiss={onClose} autoFocus canvasClassName="touch-none" />
67
+ </div>
68
+ </PopoutDialog>
69
+ );
70
+ }
@@ -0,0 +1,244 @@
1
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
2
+ import { Compartment, EditorState, type Extension } from '@codemirror/state';
3
+ import { Decoration, EditorView, keymap, lineNumbers, type DecorationSet } from '@codemirror/view';
4
+ import React, { useEffect, useRef } from 'react';
5
+ import { cn } from '../../lib/utils';
6
+ import { Button } from '../ui/button';
7
+ import type { DiagramSourceDraft } from './useDiagramSourceDraft';
8
+
9
+ /**
10
+ * The Source pane beside the canvas (owner ruling: source and diagram are
11
+ * one view; the pane opens beside the canvas and never replaces it). It
12
+ * opens on the LEFT of the canvas on the desktop row and stacks UNDER it on
13
+ * the phone; the caller (DiagramViewer) owns that placement through this
14
+ * component's `className`. A plain-text CodeMirror buffer, read-only
15
+ * without edit access and never hidden. Save, Discard, "Draft · unsaved"
16
+ * and the Reload strip live here; the canvas is the live preview. The
17
+ * selected comment's source line shows as a line mark.
18
+ *
19
+ * Not the package MarkdownEditor (markdown extensions, continuous save):
20
+ * the draft is local until Save.
21
+ */
22
+
23
+ const LINE_MARK_CLASS = 'cm-diagram-comment-line';
24
+
25
+ const paneTheme = EditorView.theme({
26
+ '&': {
27
+ height: '100%',
28
+ backgroundColor: 'var(--card)',
29
+ color: 'var(--card-foreground)',
30
+ fontFamily: 'var(--font-mono)',
31
+ },
32
+ '&.cm-focused': { outline: 'none' },
33
+ '.cm-scroller': { fontFamily: 'var(--font-mono)' },
34
+ '.cm-gutters': {
35
+ backgroundColor: 'var(--card)',
36
+ color: 'var(--muted-foreground)',
37
+ borderRight: '1px solid var(--border)',
38
+ },
39
+ '.cm-activeLineGutter': { backgroundColor: 'transparent' },
40
+ '.cm-content': { padding: '8px 0' },
41
+ '.cm-line': { padding: '0 12px' },
42
+ [`.${LINE_MARK_CLASS}`]: { backgroundColor: 'color-mix(in oklab, var(--primary) 18%, transparent)' },
43
+ });
44
+
45
+ function lineMarks(state: EditorState, range: readonly [number, number] | null): DecorationSet {
46
+ if (range === null) return Decoration.none;
47
+ const first = Math.max(1, Math.min(range[0], state.doc.lines));
48
+ const last = Math.max(first, Math.min(range[1], state.doc.lines));
49
+ const marks = [];
50
+ for (let n = first; n <= last; n += 1) {
51
+ marks.push(Decoration.line({ class: LINE_MARK_CLASS }).range(state.doc.line(n).from));
52
+ }
53
+ return Decoration.set(marks);
54
+ }
55
+
56
+ function Strip({
57
+ tone,
58
+ title,
59
+ children,
60
+ action,
61
+ dataAttr,
62
+ }: {
63
+ tone: 'accent' | 'destructive';
64
+ title: string;
65
+ children?: React.ReactNode;
66
+ action?: React.ReactNode;
67
+ dataAttr: string;
68
+ }) {
69
+ return (
70
+ <div
71
+ {...{ [dataAttr]: '' }}
72
+ role={tone === 'destructive' ? 'alert' : 'status'}
73
+ className={cn(
74
+ 'flex items-start gap-2 rounded-md border px-2.5 py-2 text-xs',
75
+ tone === 'destructive' ? 'border-destructive/40 bg-destructive/5 text-destructive' : 'border-primary/30 bg-primary/5 text-foreground',
76
+ )}
77
+ >
78
+ <div className="min-w-0 flex-1">
79
+ <div className="font-medium">{title}</div>
80
+ {children !== undefined && <div className="mt-0.5 text-muted-foreground">{children}</div>}
81
+ </div>
82
+ {action}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export function DiagramSourcePane({
88
+ draft,
89
+ editable,
90
+ markedLines,
91
+ className,
92
+ }: {
93
+ draft: DiagramSourceDraft;
94
+ editable: boolean;
95
+ /** The selected comment's source line range, marked in the gutter. */
96
+ markedLines: readonly [number, number] | null;
97
+ className?: string;
98
+ }) {
99
+ const hostRef = useRef<HTMLDivElement | null>(null);
100
+ const viewRef = useRef<EditorView | null>(null);
101
+ const editableCompartment = useRef(new Compartment());
102
+ const marksCompartment = useRef(new Compartment());
103
+ const draftRef = useRef(draft);
104
+ draftRef.current = draft;
105
+
106
+ // One view for the pane's life; props reconfigure compartments.
107
+ useEffect(() => {
108
+ const host = hostRef.current;
109
+ if (host === null) return;
110
+ const editableExtensions = (on: boolean): Extension => [EditorView.editable.of(on), EditorState.readOnly.of(!on)];
111
+ const view = new EditorView({
112
+ parent: host,
113
+ state: EditorState.create({
114
+ doc: draftRef.current.draft,
115
+ extensions: [
116
+ lineNumbers(),
117
+ history(),
118
+ EditorView.lineWrapping,
119
+ paneTheme,
120
+ keymap.of([
121
+ {
122
+ key: 'Mod-s',
123
+ run: () => {
124
+ void draftRef.current.save();
125
+ return true;
126
+ },
127
+ },
128
+ ...historyKeymap,
129
+ ...defaultKeymap,
130
+ ]),
131
+ editableCompartment.current.of(editableExtensions(editable)),
132
+ marksCompartment.current.of(EditorView.decorations.of(Decoration.none)),
133
+ EditorView.updateListener.of((update) => {
134
+ if (update.docChanged) draftRef.current.setDraft(update.state.doc.toString());
135
+ }),
136
+ ],
137
+ }),
138
+ });
139
+ viewRef.current = view;
140
+ return () => {
141
+ view.destroy();
142
+ viewRef.current = null;
143
+ };
144
+ // The initial doc and editable value seed the state; later values
145
+ // flow through the effects below.
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, []);
148
+
149
+ useEffect(() => {
150
+ viewRef.current?.dispatch({
151
+ effects: editableCompartment.current.reconfigure([EditorView.editable.of(editable), EditorState.readOnly.of(!editable)]),
152
+ });
153
+ }, [editable]);
154
+
155
+ // Discard and Reload replace the buffer from outside; typing never
156
+ // round-trips (the listener above already reported it).
157
+ useEffect(() => {
158
+ const view = viewRef.current;
159
+ if (view === null) return;
160
+ const current = view.state.doc.toString();
161
+ if (current === draft.draft) return;
162
+ view.dispatch({
163
+ changes: { from: 0, to: current.length, insert: draft.draft },
164
+ });
165
+ }, [draft.draft]);
166
+
167
+ useEffect(() => {
168
+ const view = viewRef.current;
169
+ if (view === null) return;
170
+ view.dispatch({
171
+ effects: marksCompartment.current.reconfigure(EditorView.decorations.of(lineMarks(view.state, markedLines))),
172
+ });
173
+ if (markedLines !== null && markedLines[0] <= view.state.doc.lines) {
174
+ view.dispatch({
175
+ effects: EditorView.scrollIntoView(view.state.doc.line(markedLines[0]).from, { y: 'center' }),
176
+ });
177
+ }
178
+ }, [markedLines]);
179
+
180
+ return (
181
+ <section
182
+ data-diagram-source-pane=""
183
+ aria-label="Diagram source"
184
+ className={cn('flex min-h-0 flex-col bg-card', className)}
185
+ // Escape LEAVES the buffer; it never reaches whatever is behind
186
+ // the pane. In a popout that outer handler closes it, and one
187
+ // keystroke mid-draft would take the pane, the preview and the
188
+ // person's place with it.
189
+ onKeyDown={(event) => {
190
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
191
+ event.preventDefault();
192
+ event.stopPropagation();
193
+ viewRef.current?.contentDOM.blur();
194
+ }}
195
+ >
196
+ <div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-3">
197
+ <span className="text-xs font-medium text-foreground">Source</span>
198
+ {editable && draft.dirty && (
199
+ <span data-diagram-draft-state="" className="text-[10px] text-muted-foreground">
200
+ Draft · unsaved
201
+ </span>
202
+ )}
203
+ {!editable && <span className="text-[10px] text-muted-foreground">Read only</span>}
204
+ <span className="flex-1" />
205
+ {editable && (
206
+ <>
207
+ <Button type="button" variant="ghost" size="xs" disabled={!draft.dirty || draft.saving} onClick={draft.discard}>
208
+ Discard
209
+ </Button>
210
+ <Button type="button" size="xs" disabled={!draft.dirty || draft.saving || draft.stale} onClick={() => void draft.save()}>
211
+ {draft.saving ? 'Saving…' : 'Save'}
212
+ </Button>
213
+ </>
214
+ )}
215
+ </div>
216
+ {draft.stale && (
217
+ <div className="shrink-0 border-b border-border p-2">
218
+ <Strip
219
+ tone="accent"
220
+ dataAttr="data-diagram-reload-strip"
221
+ title="Changed since you opened"
222
+ action={
223
+ <Button variant="outline" size="xs" onClick={draft.reload}>
224
+ Reload
225
+ </Button>
226
+ }
227
+ >
228
+ {draft.dirty
229
+ ? 'Someone saved a newer version. Reload to see it; your draft stays in the pane and Save is held until you do.'
230
+ : 'Someone saved a newer version. Reload to see it.'}
231
+ </Strip>
232
+ </div>
233
+ )}
234
+ {draft.saveError !== null && (
235
+ <div className="shrink-0 border-b border-border p-2">
236
+ <Strip tone="destructive" dataAttr="data-diagram-save-error" title="The diagram was not saved">
237
+ {draft.saveError}
238
+ </Strip>
239
+ </div>
240
+ )}
241
+ <div ref={hostRef} data-diagram-source-editor="" className="min-h-0 flex-1 overflow-hidden text-xs" />
242
+ </section>
243
+ );
244
+ }