@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
- package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
- package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
- package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
- package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +14 -14
- package/dist/federation/federate.js +3 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
- package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +16 -14
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +2 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
- package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
- package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
- package/eventcatalog/src/features/studio/components/icons.ts +0 -53
- package/eventcatalog/src/features/studio/components/status.ts +0 -53
- package/eventcatalog/src/features/studio/edges.ts +0 -56
- package/eventcatalog/src/features/studio/flow-state.ts +0 -92
- package/eventcatalog/src/features/studio/grouping.ts +0 -226
- package/eventcatalog/src/features/studio/layout.ts +0 -42
- package/eventcatalog/src/features/studio/levels.ts +0 -282
- package/eventcatalog/src/features/studio/node-types.ts +0 -205
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
- package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { memo, type RefObject } from 'react';
|
|
2
|
+
import type { Node } from '@xyflow/react';
|
|
3
|
+
import { Box, ExternalLink, Library } from 'lucide-react';
|
|
4
|
+
import { getCatalogLink } from '../catalog';
|
|
5
|
+
import {
|
|
6
|
+
EditableText,
|
|
7
|
+
EditStatus,
|
|
8
|
+
Field,
|
|
9
|
+
NodeCard,
|
|
10
|
+
getNodeLabel,
|
|
11
|
+
getResource,
|
|
12
|
+
isVersioned,
|
|
13
|
+
updateResource,
|
|
14
|
+
useSavedFlash,
|
|
15
|
+
type UpdateNodeData,
|
|
16
|
+
NODE_ICONS,
|
|
17
|
+
} from '@eventcatalog/visualiser/studio';
|
|
18
|
+
|
|
19
|
+
/** Something it's connected to: the node at the other end, and the connection's label */
|
|
20
|
+
export type NodeConnection = { id: string; name: string; type?: string; label?: string; outgoing: boolean };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A node's details, in a card next to it (as Studio on the web's): its type and where it is, and its name, version
|
|
24
|
+
* and summary, saved as they're typed, and what it's connected to (click one to go to it). Catalog resources are read only (they're changed in the catalog), as is
|
|
25
|
+
* everything on a locked canvas. Notes and text are edited on the canvas.
|
|
26
|
+
*/
|
|
27
|
+
export default memo(function NodeDetails({
|
|
28
|
+
node,
|
|
29
|
+
parentName,
|
|
30
|
+
connections,
|
|
31
|
+
onOpenNode,
|
|
32
|
+
containerRef,
|
|
33
|
+
locked,
|
|
34
|
+
onChange,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
/** What's shown of the node (not its position: the card follows the node on screen by itself) */
|
|
38
|
+
node: Pick<Node, 'id' | 'type' | 'data'>;
|
|
39
|
+
/** The domain or system it's in, if it's in one */
|
|
40
|
+
parentName?: string;
|
|
41
|
+
connections: NodeConnection[];
|
|
42
|
+
/** Goes to a node it's connected to (and shows its card) */
|
|
43
|
+
onOpenNode: (id: string) => void;
|
|
44
|
+
/** The canvas, to put the card next to the node */
|
|
45
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
46
|
+
locked: boolean;
|
|
47
|
+
onChange: UpdateNodeData;
|
|
48
|
+
onClose: () => void;
|
|
49
|
+
}) {
|
|
50
|
+
const { saved, showSaved } = useSavedFlash();
|
|
51
|
+
const resource = getResource(node.type, node.data);
|
|
52
|
+
const link = getCatalogLink(node);
|
|
53
|
+
const label = getNodeLabel(node.type);
|
|
54
|
+
const { icon: Icon, className: iconClassName } = NODE_ICONS[node.type ?? ''] ?? { icon: Box, className: '' };
|
|
55
|
+
const versioned = isVersioned(node.type);
|
|
56
|
+
const name = String(resource.name ?? '');
|
|
57
|
+
const version = String(link?.version ?? resource.version ?? '');
|
|
58
|
+
const summary = String(resource.summary ?? '');
|
|
59
|
+
const save = (fields: Record<string, string>) => onChange(node.id, (data) => updateResource(node.type, data, fields));
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<NodeCard
|
|
63
|
+
nodeId={node.id}
|
|
64
|
+
containerRef={containerRef}
|
|
65
|
+
label={name || label}
|
|
66
|
+
onClose={onClose}
|
|
67
|
+
kicker={
|
|
68
|
+
<>
|
|
69
|
+
{/* Its type, in its colour (as the web's type pill) */}
|
|
70
|
+
<span className={`flex-none ${iconClassName}`}>
|
|
71
|
+
<span className="inline-flex items-center gap-1 rounded bg-current px-1.5 text-[9px] font-bold uppercase leading-4 tracking-wider">
|
|
72
|
+
<Icon aria-hidden size={10} className="text-white" />
|
|
73
|
+
<span className="text-white">{label}</span>
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
{parentName && <span className="ec-doc-card-breadcrumb">in {parentName}</span>}
|
|
77
|
+
</>
|
|
78
|
+
}
|
|
79
|
+
header={
|
|
80
|
+
link || locked ? (
|
|
81
|
+
<>
|
|
82
|
+
<h2 className="ec-doc-card-title">
|
|
83
|
+
{name}
|
|
84
|
+
{version && <span className="ec-doc-card-version">v{version}</span>}
|
|
85
|
+
</h2>
|
|
86
|
+
{summary && <p className="ec-doc-card-summary">{summary}</p>}
|
|
87
|
+
{link && (
|
|
88
|
+
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
89
|
+
<span className="inline-flex items-center gap-1.5">
|
|
90
|
+
<Library aria-hidden size={13} className="text-[rgb(var(--ec-accent))]" />
|
|
91
|
+
From your catalog: change it there
|
|
92
|
+
</span>
|
|
93
|
+
<a
|
|
94
|
+
href={link.url}
|
|
95
|
+
target="_blank"
|
|
96
|
+
rel="noreferrer"
|
|
97
|
+
className="inline-flex items-center gap-1 font-medium text-[rgb(var(--ec-accent))] hover:underline"
|
|
98
|
+
>
|
|
99
|
+
Open in EventCatalog <ExternalLink aria-hidden size={12} />
|
|
100
|
+
</a>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
// A small form: what's typed is saved as it's typed (there's nothing to press), and shown on the node
|
|
106
|
+
<div className="ec-edit-form" key={node.id}>
|
|
107
|
+
<Field label="Name">
|
|
108
|
+
<EditableText
|
|
109
|
+
label="Name"
|
|
110
|
+
value={name}
|
|
111
|
+
onSave={(next) => save({ name: next })}
|
|
112
|
+
onSaved={showSaved}
|
|
113
|
+
placeholder={`Name this ${label.toLowerCase()}`}
|
|
114
|
+
className="ec-editable-title"
|
|
115
|
+
required
|
|
116
|
+
/>
|
|
117
|
+
</Field>
|
|
118
|
+
{versioned && (
|
|
119
|
+
<Field label="Version" className="ec-field-version">
|
|
120
|
+
<EditableText
|
|
121
|
+
label="Version"
|
|
122
|
+
value={version || '0.0.1'}
|
|
123
|
+
onSave={(next) => save({ version: next })}
|
|
124
|
+
onSaved={showSaved}
|
|
125
|
+
placeholder="0.0.1"
|
|
126
|
+
required
|
|
127
|
+
/>
|
|
128
|
+
</Field>
|
|
129
|
+
)}
|
|
130
|
+
<Field label="Summary">
|
|
131
|
+
<EditableText
|
|
132
|
+
label="Summary"
|
|
133
|
+
value={summary}
|
|
134
|
+
onSave={(next) => save({ summary: next })}
|
|
135
|
+
onSaved={showSaved}
|
|
136
|
+
placeholder="What it does, in a sentence or two"
|
|
137
|
+
className="ec-editable-summary"
|
|
138
|
+
multiline
|
|
139
|
+
/>
|
|
140
|
+
</Field>
|
|
141
|
+
<EditStatus saved={saved} />
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
<div className="ec-doc-card-body px-4 pb-3.5 pt-1 empty:hidden">
|
|
147
|
+
{(['Connects to', 'Connected from'] as const).map((heading) => {
|
|
148
|
+
const items = connections.filter((connection) => connection.outgoing === (heading === 'Connects to'));
|
|
149
|
+
if (!items.length) return null;
|
|
150
|
+
return (
|
|
151
|
+
<section key={heading} className="mt-3">
|
|
152
|
+
<h3 className="mb-1.5 text-xs font-semibold text-[rgb(var(--ec-page-text-muted))]">{heading}</h3>
|
|
153
|
+
<ul className="space-y-0.5">
|
|
154
|
+
{items.map((item) => {
|
|
155
|
+
const { icon: ItemIcon, className: itemIconClassName } = NODE_ICONS[item.type ?? ''] ?? {
|
|
156
|
+
icon: Box,
|
|
157
|
+
className: '',
|
|
158
|
+
};
|
|
159
|
+
return (
|
|
160
|
+
<li key={`${item.id}-${item.label}`}>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
title={`Go to ${item.name}`}
|
|
164
|
+
onClick={() => onOpenNode(item.id)}
|
|
165
|
+
className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm hover:bg-[rgb(var(--ec-content-hover))]"
|
|
166
|
+
>
|
|
167
|
+
<ItemIcon aria-hidden size={15} className={`flex-none ${itemIconClassName}`} />
|
|
168
|
+
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
|
169
|
+
{item.label && (
|
|
170
|
+
<span className="flex-none text-[11px] text-[rgb(var(--ec-page-text-muted))]">{item.label}</span>
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
</li>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</ul>
|
|
177
|
+
</section>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
</NodeCard>
|
|
182
|
+
);
|
|
183
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState, type FormEvent } from 'react';
|
|
2
|
+
import { MAX_TITLE_LENGTH } from '../limits';
|
|
3
|
+
import Dialog, { PRIMARY_BUTTON, SECONDARY_BUTTON } from './Dialog';
|
|
4
|
+
import { STATUS } from '@eventcatalog/visualiser/studio';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Naming the canvas a diagram is opened in (the visualiser's "Open in Studio"), named after the diagram to start
|
|
8
|
+
* with, like a new canvas on the Studio page. Loaded with the visualiser, so it imports nothing that loads a canvas.
|
|
9
|
+
*/
|
|
10
|
+
export default function OpenInStudioDialog({
|
|
11
|
+
title,
|
|
12
|
+
onOpen,
|
|
13
|
+
onClose,
|
|
14
|
+
}: {
|
|
15
|
+
/** The diagram's title */
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Makes the canvas with this name and opens it (rejects with what went wrong) */
|
|
18
|
+
onOpen: (title: string | undefined) => Promise<void>;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
}) {
|
|
21
|
+
const [name, setName] = useState((title ?? '').slice(0, MAX_TITLE_LENGTH));
|
|
22
|
+
const [opening, setOpening] = useState(false);
|
|
23
|
+
const [error, setError] = useState<string>();
|
|
24
|
+
|
|
25
|
+
const submit = async (event: FormEvent) => {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
setOpening(true);
|
|
28
|
+
setError(undefined);
|
|
29
|
+
try {
|
|
30
|
+
// Stays open (and busy) while the canvas page loads
|
|
31
|
+
await onOpen(name.trim() || undefined);
|
|
32
|
+
} catch (failure) {
|
|
33
|
+
setError((failure as Error).message);
|
|
34
|
+
setOpening(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog labelledBy="open-in-studio-title" busy={opening} onClose={onClose}>
|
|
40
|
+
<form onSubmit={submit}>
|
|
41
|
+
<div className="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
|
|
42
|
+
<h2 id="open-in-studio-title" className="text-base font-semibold">
|
|
43
|
+
Open in Studio
|
|
44
|
+
</h2>
|
|
45
|
+
<p className="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
46
|
+
A new canvas starts with this diagram. Name it for what you're designing, so your team can find it.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="px-5 py-4">
|
|
50
|
+
<label htmlFor="open-in-studio-name" className="block text-sm font-semibold">
|
|
51
|
+
Name
|
|
52
|
+
</label>
|
|
53
|
+
<input
|
|
54
|
+
id="open-in-studio-name"
|
|
55
|
+
// Ready to type a new name, or keep the diagram's
|
|
56
|
+
autoFocus
|
|
57
|
+
onFocus={(event) => event.target.select()}
|
|
58
|
+
value={name}
|
|
59
|
+
onChange={(event) => setName(event.target.value)}
|
|
60
|
+
maxLength={MAX_TITLE_LENGTH}
|
|
61
|
+
autoComplete="off"
|
|
62
|
+
placeholder="e.g. Payments redesign"
|
|
63
|
+
disabled={opening}
|
|
64
|
+
className="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
|
|
65
|
+
/>
|
|
66
|
+
{error && <p className={`mt-2 text-sm ${STATUS.danger.text}`}>{error}</p>}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
|
|
69
|
+
<button type="button" onClick={onClose} disabled={opening} className={SECONDARY_BUTTON}>
|
|
70
|
+
Cancel
|
|
71
|
+
</button>
|
|
72
|
+
<button type="submit" disabled={opening} className={PRIMARY_BUTTON}>
|
|
73
|
+
{opening ? 'Opening…' : 'Open in Studio'}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { Fragment, memo, useEffect, useLayoutEffect, useRef, useSyncExternalStore, type MutableRefObject } from 'react';
|
|
2
|
-
import { useInternalNode, useStore, useStoreApi,
|
|
3
|
-
import {
|
|
4
|
-
import { sizeOf } from '
|
|
2
|
+
import { useInternalNode, useStore, useStoreApi, type XYPosition } from '@xyflow/react';
|
|
3
|
+
import { Eye, EyeOff } from 'lucide-react';
|
|
4
|
+
import { CanvasPortal, sizeOf, COUNTER_SCALE, useCounterScale, Picture, AgentMark } from '@eventcatalog/visualiser/studio';
|
|
5
5
|
import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
|
|
6
6
|
import { useFollowCamera } from '../hooks/use-follow-camera';
|
|
7
|
-
import Picture from './Picture';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Other people on the canvas: their pointers, selections and the connections they're dragging. Drawn in the
|
|
11
10
|
* canvas's own coordinates (React Flow's viewport portal), so panning and zooming move them without
|
|
12
|
-
* re-rendering, and counter-scaled so they stay the same size on screen. Each
|
|
13
|
-
* them on their own, so one pointer moving doesn't re-render anyone else's.
|
|
11
|
+
* re-rendering, and counter-scaled (COUNTER_SCALE, set outside React) so they stay the same size on screen. Each
|
|
12
|
+
* person's pointer and marks follow them on their own, so one pointer moving doesn't re-render anyone else's.
|
|
14
13
|
*/
|
|
15
14
|
export const RemotePresence = memo(function RemotePresence({
|
|
16
15
|
store,
|
|
@@ -20,22 +19,22 @@ export const RemotePresence = memo(function RemotePresence({
|
|
|
20
19
|
clientId: number | null;
|
|
21
20
|
}) {
|
|
22
21
|
const people = usePeople(store);
|
|
23
|
-
const
|
|
24
|
-
|
|
22
|
+
const layer = useRef<HTMLDivElement>(null);
|
|
23
|
+
useCounterScale(layer);
|
|
25
24
|
const others = store ? people.filter((peer) => peer.clientId !== clientId) : [];
|
|
26
25
|
|
|
27
26
|
return (
|
|
28
|
-
<
|
|
27
|
+
<CanvasPortal>
|
|
29
28
|
{/* Above nodes (selected ones are raised to 1000) */}
|
|
30
|
-
<div className="pointer-events-none absolute left-0 top-0" style={{ zIndex: 2000 }}>
|
|
29
|
+
<div ref={layer} className="pointer-events-none absolute left-0 top-0" style={{ zIndex: 2000 }}>
|
|
31
30
|
{others.map((peer) => (
|
|
32
31
|
<Fragment key={peer.clientId}>
|
|
33
|
-
<PeerMarks store={store!} clientId={peer.clientId}
|
|
34
|
-
{peer.pointer && <PeerPointer store={store!} peer={peer}
|
|
32
|
+
<PeerMarks store={store!} clientId={peer.clientId} />
|
|
33
|
+
{peer.pointer && <PeerPointer store={store!} peer={peer} />}
|
|
35
34
|
</Fragment>
|
|
36
35
|
))}
|
|
37
36
|
</div>
|
|
38
|
-
</
|
|
37
|
+
</CanvasPortal>
|
|
39
38
|
);
|
|
40
39
|
});
|
|
41
40
|
|
|
@@ -51,33 +50,39 @@ const marksKey = (peer: Peer | undefined) =>
|
|
|
51
50
|
: '';
|
|
52
51
|
|
|
53
52
|
/** Someone's selection, and the connection they're dragging (a dashed line from where it starts to their pointer) */
|
|
54
|
-
const PeerMarks = memo(function PeerMarks({ store, clientId
|
|
53
|
+
const PeerMarks = memo(function PeerMarks({ store, clientId }: { store: PresenceStore; clientId: number }) {
|
|
55
54
|
useSyncExternalStore(store.subscribe, () => marksKey(findPeer(store, clientId)));
|
|
56
55
|
const peer = findPeer(store, clientId);
|
|
57
56
|
if (!peer) return null;
|
|
58
57
|
return (
|
|
59
58
|
<>
|
|
60
59
|
{peer.selection.map((id) => (
|
|
61
|
-
<SelectionOutline key={id} nodeId={id} name={peer.name} color={peer.color}
|
|
60
|
+
<SelectionOutline key={id} nodeId={id} name={peer.name} color={peer.color} />
|
|
62
61
|
))}
|
|
63
|
-
{peer.pointer && peer.connecting &&
|
|
64
|
-
<svg className="absolute left-0 top-0 overflow-visible" width={1} height={1}>
|
|
65
|
-
<line
|
|
66
|
-
x1={peer.connecting.x}
|
|
67
|
-
y1={peer.connecting.y}
|
|
68
|
-
x2={peer.pointer.x}
|
|
69
|
-
y2={peer.pointer.y}
|
|
70
|
-
stroke={peer.color}
|
|
71
|
-
strokeWidth={2 * scale}
|
|
72
|
-
strokeDasharray={`${6 * scale} ${4 * scale}`}
|
|
73
|
-
/>
|
|
74
|
-
<circle cx={peer.connecting.x} cy={peer.connecting.y} r={4 * scale} fill={peer.color} />
|
|
75
|
-
</svg>
|
|
76
|
-
)}
|
|
62
|
+
{peer.pointer && peer.connecting && <ConnectingLine from={peer.connecting} to={peer.pointer} color={peer.color} />}
|
|
77
63
|
</>
|
|
78
64
|
);
|
|
79
65
|
});
|
|
80
66
|
|
|
67
|
+
/** A connection someone's dragging: only shown while they drag one, so it can follow the zoom itself */
|
|
68
|
+
function ConnectingLine({ from, to, color }: { from: XYPosition; to: XYPosition; color: string }) {
|
|
69
|
+
const scale = 1 / useStore((state) => state.transform[2]);
|
|
70
|
+
return (
|
|
71
|
+
<svg className="absolute left-0 top-0 overflow-visible" width={1} height={1}>
|
|
72
|
+
<line
|
|
73
|
+
x1={from.x}
|
|
74
|
+
y1={from.y}
|
|
75
|
+
x2={to.x}
|
|
76
|
+
y2={to.y}
|
|
77
|
+
stroke={color}
|
|
78
|
+
strokeWidth={2 * scale}
|
|
79
|
+
strokeDasharray={`${6 * scale} ${4 * scale}`}
|
|
80
|
+
/>
|
|
81
|
+
<circle cx={from.x} cy={from.y} r={4 * scale} fill={color} />
|
|
82
|
+
</svg>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
81
86
|
/** Longest a pointer takes to glide to where it was last seen (updates come about every 33 ms) */
|
|
82
87
|
const MAX_GLIDE_MS = 120;
|
|
83
88
|
|
|
@@ -85,19 +90,15 @@ const MAX_GLIDE_MS = 120;
|
|
|
85
90
|
* Someone's pointer, gliding from where it was to each new position over the time since the last one (so a
|
|
86
91
|
* steady stream of updates looks like steady movement). Moved with a transform outside React: no re-renders.
|
|
87
92
|
*/
|
|
88
|
-
const PeerPointer = memo(function PeerPointer({ store, peer
|
|
93
|
+
const PeerPointer = memo(function PeerPointer({ store, peer }: { store: PresenceStore; peer: Peer }) {
|
|
89
94
|
const ref = useRef<HTMLDivElement>(null);
|
|
90
95
|
const shown = useRef<XYPosition | null>(null);
|
|
91
|
-
const scaleRef = useRef(scale);
|
|
92
|
-
scaleRef.current = scale;
|
|
93
96
|
|
|
94
97
|
const draw = () => {
|
|
95
98
|
if (ref.current && shown.current)
|
|
96
|
-
ref.current.style.transform = `translate(${shown.current.x}px, ${shown.current.y}px) scale(${
|
|
99
|
+
ref.current.style.transform = `translate(${shown.current.x}px, ${shown.current.y}px) scale(var(${COUNTER_SCALE}, 1))`;
|
|
97
100
|
};
|
|
98
101
|
|
|
99
|
-
useLayoutEffect(draw, [scale]);
|
|
100
|
-
|
|
101
102
|
useLayoutEffect(() => {
|
|
102
103
|
let target: XYPosition | null = null;
|
|
103
104
|
let from: XYPosition = { x: 0, y: 0 };
|
|
@@ -148,7 +149,7 @@ const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: P
|
|
|
148
149
|
className="absolute left-4 top-4 flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white"
|
|
149
150
|
style={{ backgroundColor: peer.color }}
|
|
150
151
|
>
|
|
151
|
-
{peer.agent && <
|
|
152
|
+
{peer.agent && <AgentMark name={peer.name} size={12} />}
|
|
152
153
|
<Picture src={peer.picture} className="-ml-0.5 h-4 w-4 shrink-0" />
|
|
153
154
|
{peer.name}
|
|
154
155
|
{peer.activity && <span className="font-normal opacity-90">· {peer.activity}</span>}
|
|
@@ -157,17 +158,18 @@ const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: P
|
|
|
157
158
|
);
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
// Screen pixels, in the canvas's coordinates (counter-scaled by the layer's COUNTER_SCALE)
|
|
162
|
+
const onScreen = (px: number) => `calc(${px}px * var(${COUNTER_SCALE}, 1))`;
|
|
163
|
+
|
|
160
164
|
/** Someone's selection around a node: follows that node only, so other nodes moving don't re-render it */
|
|
161
165
|
const SelectionOutline = memo(function SelectionOutline({
|
|
162
166
|
nodeId,
|
|
163
167
|
name,
|
|
164
168
|
color,
|
|
165
|
-
scale,
|
|
166
169
|
}: {
|
|
167
170
|
nodeId: string;
|
|
168
171
|
name: string;
|
|
169
172
|
color: string;
|
|
170
|
-
scale: number;
|
|
171
173
|
}) {
|
|
172
174
|
const node = useInternalNode(nodeId);
|
|
173
175
|
if (!node) return null;
|
|
@@ -177,17 +179,21 @@ const SelectionOutline = memo(function SelectionOutline({
|
|
|
177
179
|
<div
|
|
178
180
|
className="absolute"
|
|
179
181
|
style={{
|
|
180
|
-
left: x - 4
|
|
181
|
-
top: y - 4
|
|
182
|
-
width: width + 8
|
|
183
|
-
height: height + 8
|
|
184
|
-
border: `${2
|
|
185
|
-
borderRadius: 8
|
|
182
|
+
left: `calc(${x}px - ${onScreen(4)})`,
|
|
183
|
+
top: `calc(${y}px - ${onScreen(4)})`,
|
|
184
|
+
width: `calc(${width}px + ${onScreen(8)})`,
|
|
185
|
+
height: `calc(${height}px + ${onScreen(8)})`,
|
|
186
|
+
border: `${onScreen(2)} solid ${color}`,
|
|
187
|
+
borderRadius: onScreen(8),
|
|
186
188
|
}}
|
|
187
189
|
>
|
|
188
190
|
<span
|
|
189
191
|
className="absolute right-0 top-0 whitespace-nowrap rounded px-1.5 text-[10px] leading-4 text-white"
|
|
190
|
-
style={{
|
|
192
|
+
style={{
|
|
193
|
+
backgroundColor: color,
|
|
194
|
+
transform: `translateY(-100%) scale(var(${COUNTER_SCALE}, 1))`,
|
|
195
|
+
transformOrigin: 'bottom right',
|
|
196
|
+
}}
|
|
191
197
|
>
|
|
192
198
|
{name}
|
|
193
199
|
</span>
|
|
@@ -253,6 +259,7 @@ export const AgentActivity = memo(function AgentActivity({
|
|
|
253
259
|
const working = people.filter((peer) => peer.clientId !== clientId && peer.agent && peer.pointer);
|
|
254
260
|
const camera = useFollowCamera({
|
|
255
261
|
enabled: following && enabled,
|
|
262
|
+
fitsContent: enabled,
|
|
256
263
|
agentsWorking: working.length > 0,
|
|
257
264
|
getAgents: () => (store?.getPeers() ?? []).filter((peer: Peer) => peer.clientId !== clientId && peer.agent && peer.pointer),
|
|
258
265
|
});
|