@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
|
@@ -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
|
+
}
|