Skip to main content

open_gpui_canvas/
clipboard.rs

1use crate::{
2    BindingId, CanvasDocument, CanvasEdge, CanvasEndpoint, CanvasNode, CanvasRecordId,
3    CanvasRecordRelations, CanvasSelection, CanvasShape, CanvasTransaction, DocumentCommand,
4    EdgeId, NodeId, ShapeId,
5    record_scope::{CanvasRecordScopeOptions, resolve_selection_scope_with_predicates},
6};
7use indexmap::IndexMap;
8use open_gpui::{Pixels, Point};
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12pub struct CanvasClipboardPayload {
13    #[serde(default)]
14    pub nodes: Vec<CanvasNode>,
15    #[serde(default)]
16    pub edges: Vec<CanvasEdge>,
17    #[serde(default)]
18    pub shapes: Vec<CanvasShape>,
19    #[serde(default)]
20    pub selection: CanvasSelection,
21    #[serde(default)]
22    pub relations: CanvasRecordRelations,
23}
24
25impl CanvasClipboardPayload {
26    pub fn from_document_selection(document: &CanvasDocument, selection: &CanvasSelection) -> Self {
27        let scope = resolve_selection_scope_with_predicates(
28            document,
29            selection,
30            CanvasRecordScopeOptions::structural_with_internal_edges(),
31            |record_id| document.contains_record(record_id),
32            |record_id| is_copyable_record(document, record_id),
33        );
34        let copied_records = scope.action_records();
35        let selection = copied_selection(scope.normalized_selection(), copied_records);
36
37        let mut nodes = Vec::new();
38        let mut edges = Vec::new();
39        let mut shapes = Vec::new();
40        for record_id in copied_records.records() {
41            match record_id {
42                CanvasRecordId::Node(id) => {
43                    if let Some(node) = document.node(id) {
44                        nodes.push(node.clone());
45                    }
46                }
47                CanvasRecordId::Edge(id) => {
48                    if let Some(edge) = document.edge(id) {
49                        edges.push(edge.clone());
50                    }
51                }
52                CanvasRecordId::Shape(id) => {
53                    if let Some(shape) = document.shape(id) {
54                        shapes.push(shape.clone());
55                    }
56                }
57            }
58        }
59
60        Self {
61            nodes,
62            edges,
63            shapes,
64            selection,
65            relations: document
66                .relations()
67                .subset_for_records(|record_id| copied_records.contains(record_id)),
68        }
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.nodes.is_empty() && self.edges.is_empty() && self.shapes.is_empty()
73    }
74
75    pub fn paste_transaction(
76        &self,
77        document: &CanvasDocument,
78        offset: Point<Pixels>,
79    ) -> CanvasPasteTransaction {
80        let node_ids = remap_ids(
81            self.nodes.iter().map(|node| node.id.as_str()),
82            |id| document.contains_node(&NodeId::from(id.to_owned())),
83            unique_node_id,
84        );
85        let edge_ids = remap_ids(
86            self.edges.iter().map(|edge| edge.id.as_str()),
87            |id| document.contains_edge(&EdgeId::from(id.to_owned())),
88            unique_edge_id,
89        );
90        let shape_ids = remap_ids(
91            self.shapes.iter().map(|shape| shape.id.as_str()),
92            |id| document.contains_shape(&ShapeId::from(id.to_owned())),
93            unique_shape_id,
94        );
95        let binding_ids = remap_ids(
96            self.relations.bindings().map(|binding| binding.id.as_str()),
97            |id| {
98                document
99                    .relations()
100                    .binding(&BindingId::from(id.to_owned()))
101                    .is_some()
102            },
103            unique_binding_id,
104        );
105
106        let mut fallback_selection = CanvasSelection::default();
107        let mut commands = Vec::new();
108
109        for node in &self.nodes {
110            let mut node = node.clone();
111            node.id = node_ids[&node.id].clone();
112            node.position += offset;
113            fallback_selection.insert_node(node.id.clone());
114            commands.push(DocumentCommand::InsertNode(node));
115        }
116
117        for shape in &self.shapes {
118            let mut shape = shape.clone();
119            shape.id = shape_ids[&shape.id].clone();
120            shape.bounds.origin += offset;
121            fallback_selection.insert_shape(shape.id.clone());
122            commands.push(DocumentCommand::InsertShape(shape));
123        }
124
125        let mut pasted_edge_ids = IndexMap::new();
126        for edge in &self.edges {
127            let Some(source) = remap_endpoint(&edge.source, &node_ids) else {
128                continue;
129            };
130            let Some(target) = remap_endpoint(&edge.target, &node_ids) else {
131                continue;
132            };
133            let mut edge = edge.clone();
134            let original_edge_id = edge.id.clone();
135            edge.id = edge_ids[&original_edge_id].clone();
136            edge.source = source;
137            edge.target = target;
138            fallback_selection.insert_edge(edge.id.clone());
139            pasted_edge_ids.insert(original_edge_id, edge.id.clone());
140            commands.push(DocumentCommand::InsertEdge(edge));
141        }
142
143        for relation in self.relations.parents() {
144            let Some(child) =
145                remap_record_id(&relation.child, &node_ids, &pasted_edge_ids, &shape_ids)
146            else {
147                continue;
148            };
149            let Some(parent) =
150                remap_record_id(&relation.parent, &node_ids, &pasted_edge_ids, &shape_ids)
151            else {
152                continue;
153            };
154            commands.push(DocumentCommand::SetRecordParent { child, parent });
155        }
156
157        for relation in self.relations.groups() {
158            let Some(group) =
159                remap_record_id(&relation.group, &node_ids, &pasted_edge_ids, &shape_ids)
160            else {
161                continue;
162            };
163            let Some(member) =
164                remap_record_id(&relation.member, &node_ids, &pasted_edge_ids, &shape_ids)
165            else {
166                continue;
167            };
168            commands.push(DocumentCommand::AddRecordToGroup { group, member });
169        }
170
171        for relation in self.relations.bindings() {
172            let Some(source) =
173                remap_record_id(&relation.source, &node_ids, &pasted_edge_ids, &shape_ids)
174            else {
175                continue;
176            };
177            let Some(target) =
178                remap_record_id(&relation.target, &node_ids, &pasted_edge_ids, &shape_ids)
179            else {
180                continue;
181            };
182            let mut binding = relation.clone();
183            binding.id = binding_ids[&binding.id].clone();
184            binding.source = source;
185            binding.target = target;
186            commands.push(DocumentCommand::SetRecordBinding(binding));
187        }
188
189        let selection = remap_selection(&self.selection, &node_ids, &pasted_edge_ids, &shape_ids)
190            .filter(|selection| !selection.is_empty())
191            .unwrap_or(fallback_selection);
192
193        CanvasPasteTransaction {
194            transaction: CanvasTransaction::new(commands),
195            selection,
196        }
197    }
198}
199
200#[derive(Clone, Debug, PartialEq)]
201pub struct CanvasPasteTransaction {
202    pub transaction: CanvasTransaction,
203    pub selection: CanvasSelection,
204}
205
206fn is_copyable_record(document: &CanvasDocument, record_id: &CanvasRecordId) -> bool {
207    match record_id {
208        CanvasRecordId::Node(id) => document.node(id).is_some_and(|node| !node.locked),
209        CanvasRecordId::Edge(id) => document.edge(id).is_some_and(|edge| !edge.locked),
210        CanvasRecordId::Shape(id) => document.shape(id).is_some_and(|shape| !shape.locked),
211    }
212}
213
214fn copied_selection(
215    selection: &CanvasSelection,
216    copied_records: &crate::CanvasRecordScope,
217) -> CanvasSelection {
218    let mut copied_selection = CanvasSelection::default();
219    for record_id in selection
220        .selected_records()
221        .filter(|record_id| copied_records.contains(record_id))
222    {
223        copied_selection.insert_record(record_id);
224    }
225    for endpoint in selection.selected_handles() {
226        if copied_records.contains(&CanvasRecordId::Node(endpoint.node_id.clone())) {
227            copied_selection.insert_handle(endpoint.clone());
228        }
229    }
230    copied_selection
231}
232
233fn remap_ids<'a, I, T, Exists, Unique>(ids: I, exists: Exists, unique: Unique) -> IndexMap<T, T>
234where
235    I: IntoIterator<Item = &'a str>,
236    T: Clone + From<String> + std::hash::Hash + Eq,
237    Exists: Fn(&str) -> bool,
238    Unique: Fn(&str, &mut dyn FnMut(&str) -> bool) -> T,
239{
240    let mut remapped = IndexMap::new();
241    for id in ids {
242        let mut taken = |candidate: &str| {
243            exists(candidate)
244                || remapped
245                    .values()
246                    .any(|mapped: &T| mapped.clone() == T::from(candidate.to_owned()))
247        };
248        remapped.insert(T::from(id.to_owned()), unique(id, &mut taken));
249    }
250    remapped
251}
252
253fn unique_node_id(base: &str, taken: &mut dyn FnMut(&str) -> bool) -> NodeId {
254    NodeId::new(unique_id(base, taken))
255}
256
257fn unique_edge_id(base: &str, taken: &mut dyn FnMut(&str) -> bool) -> EdgeId {
258    EdgeId::new(unique_id(base, taken))
259}
260
261fn unique_shape_id(base: &str, taken: &mut dyn FnMut(&str) -> bool) -> ShapeId {
262    ShapeId::new(unique_id(base, taken))
263}
264
265fn unique_binding_id(base: &str, taken: &mut dyn FnMut(&str) -> bool) -> BindingId {
266    BindingId::new(unique_id(base, taken))
267}
268
269fn unique_id(base: &str, taken: &mut dyn FnMut(&str) -> bool) -> String {
270    let copy = format!("{base}-copy");
271    if !taken(&copy) {
272        return copy;
273    }
274
275    for index in 2.. {
276        let candidate = format!("{base}-copy-{index}");
277        if !taken(&candidate) {
278            return candidate;
279        }
280    }
281
282    unreachable!("unbounded id search should always return")
283}
284
285fn remap_endpoint(
286    endpoint: &CanvasEndpoint,
287    node_ids: &IndexMap<NodeId, NodeId>,
288) -> Option<CanvasEndpoint> {
289    node_ids
290        .get(&endpoint.node_id)
291        .cloned()
292        .map(|node_id| CanvasEndpoint {
293            node_id,
294            handle_id: endpoint.handle_id.clone(),
295        })
296}
297
298fn remap_selection(
299    selection: &CanvasSelection,
300    node_ids: &IndexMap<NodeId, NodeId>,
301    edge_ids: &IndexMap<EdgeId, EdgeId>,
302    shape_ids: &IndexMap<ShapeId, ShapeId>,
303) -> Option<CanvasSelection> {
304    let mut remapped = CanvasSelection::default();
305    for record_id in selection.selected_records() {
306        remapped.insert_record(remap_record_id(&record_id, node_ids, edge_ids, shape_ids)?);
307    }
308    for endpoint in selection.selected_handles() {
309        remapped.insert_handle(remap_endpoint(endpoint, node_ids)?);
310    }
311
312    Some(remapped)
313}
314
315fn remap_record_id(
316    id: &CanvasRecordId,
317    node_ids: &IndexMap<NodeId, NodeId>,
318    edge_ids: &IndexMap<EdgeId, EdgeId>,
319    shape_ids: &IndexMap<ShapeId, ShapeId>,
320) -> Option<CanvasRecordId> {
321    match id {
322        CanvasRecordId::Node(id) => node_ids.get(id).cloned().map(CanvasRecordId::Node),
323        CanvasRecordId::Edge(id) => edge_ids.get(id).cloned().map(CanvasRecordId::Edge),
324        CanvasRecordId::Shape(id) => shape_ids.get(id).cloned().map(CanvasRecordId::Shape),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::test_support::document_fixture;
332    use crate::{
333        BindingId, CanvasEdge, CanvasEndpoint, CanvasNode, CanvasRecordBindingRelation, CanvasShape,
334    };
335    use open_gpui::{Bounds, point, px, size};
336
337    #[test]
338    fn copy_selection_includes_internal_edges() {
339        let document = connected_document();
340        let mut selection = CanvasSelection::default();
341        selection.insert_node(NodeId::from("a"));
342        selection.insert_node(NodeId::from("b"));
343
344        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
345
346        assert_eq!(
347            payload
348                .nodes
349                .iter()
350                .map(|node| &node.id)
351                .collect::<Vec<_>>(),
352            vec![&NodeId::from("a"), &NodeId::from("b")]
353        );
354        assert_eq!(
355            payload
356                .edges
357                .iter()
358                .map(|edge| &edge.id)
359                .collect::<Vec<_>>(),
360            vec![&EdgeId::from("a-b")]
361        );
362    }
363
364    #[test]
365    fn copy_selection_omits_external_edges() {
366        let document = connected_document();
367        let mut selection = CanvasSelection::default();
368        selection.insert_node(NodeId::from("a"));
369
370        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
371
372        assert_eq!(payload.nodes.len(), 1);
373        assert!(payload.edges.is_empty());
374    }
375
376    #[test]
377    fn paste_payload_remaps_ids_and_offsets_records() {
378        let mut document = connected_document();
379        document
380            .insert_shape(CanvasShape::new(
381                "note",
382                Bounds::new(point(px(0.0), px(20.0)), size(px(30.0), px(30.0))),
383            ))
384            .unwrap();
385        let mut selection = CanvasSelection::default();
386        selection.insert_node(NodeId::from("a"));
387        selection.insert_node(NodeId::from("b"));
388        selection.insert_shape(ShapeId::from("note"));
389        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
390
391        let pasted = payload.paste_transaction(&document, point(px(16.0), px(24.0)));
392
393        assert_eq!(
394            pasted
395                .selection
396                .selected_nodes()
397                .cloned()
398                .collect::<Vec<_>>(),
399            vec![NodeId::from("a-copy"), NodeId::from("b-copy")]
400        );
401        assert_eq!(
402            pasted
403                .selection
404                .selected_edges()
405                .cloned()
406                .collect::<Vec<_>>(),
407            Vec::<EdgeId>::new()
408        );
409        assert_eq!(
410            pasted
411                .selection
412                .selected_shapes()
413                .cloned()
414                .collect::<Vec<_>>(),
415            vec![ShapeId::from("note-copy")]
416        );
417
418        let mut draft = document.clone();
419        draft.apply_transaction(pasted.transaction).unwrap();
420        assert!(draft.contains_edge(&EdgeId::from("a-b-copy")));
421        assert_eq!(
422            draft.node(&NodeId::from("a-copy")).unwrap().position,
423            point(px(16.0), px(24.0))
424        );
425        assert_eq!(
426            draft
427                .edge(&EdgeId::from("a-b-copy"))
428                .unwrap()
429                .source
430                .node_id,
431            NodeId::from("a-copy")
432        );
433        assert_eq!(
434            draft
435                .shape(&ShapeId::from("note-copy"))
436                .unwrap()
437                .bounds
438                .origin,
439            point(px(16.0), px(44.0))
440        );
441    }
442
443    #[test]
444    fn copy_selection_includes_internal_relations() {
445        let document = related_document();
446        let mut selection = CanvasSelection::default();
447        selection.insert_node(NodeId::from("child"));
448        selection.insert_shape(ShapeId::from("group"));
449
450        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
451
452        let child = CanvasRecordId::Node(NodeId::from("child"));
453        let group = CanvasRecordId::Shape(ShapeId::from("group"));
454        assert_eq!(payload.relations.parent_of(&child), Some(&group));
455        assert_eq!(
456            payload
457                .relations
458                .members_of(&group)
459                .cloned()
460                .collect::<Vec<_>>(),
461            vec![child.clone()]
462        );
463        assert_eq!(
464            payload.relations.binding(&BindingId::from("binding")),
465            Some(&CanvasRecordBindingRelation::new(
466                "binding",
467                child,
468                group.clone()
469            ))
470        );
471    }
472
473    #[test]
474    fn copy_selection_expands_related_descendants() {
475        let document = related_tree_document();
476        let mut selection = CanvasSelection::default();
477        selection.insert_shape(ShapeId::from("frame"));
478
479        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
480
481        assert_eq!(
482            payload
483                .nodes
484                .iter()
485                .map(|node| &node.id)
486                .collect::<Vec<_>>(),
487            vec![&NodeId::from("child"), &NodeId::from("peer")]
488        );
489        assert_eq!(
490            payload
491                .edges
492                .iter()
493                .map(|edge| &edge.id)
494                .collect::<Vec<_>>(),
495            vec![&EdgeId::from("child-peer")]
496        );
497        assert_eq!(
498            payload
499                .shapes
500                .iter()
501                .map(|shape| &shape.id)
502                .collect::<Vec<_>>(),
503            vec![&ShapeId::from("frame"), &ShapeId::from("group")]
504        );
505
506        let frame = CanvasRecordId::Shape(ShapeId::from("frame"));
507        let group = CanvasRecordId::Shape(ShapeId::from("group"));
508        let child = CanvasRecordId::Node(NodeId::from("child"));
509        let peer = CanvasRecordId::Node(NodeId::from("peer"));
510        assert_eq!(payload.relations.parent_of(&group), Some(&frame));
511        assert_eq!(
512            payload
513                .relations
514                .members_of(&group)
515                .cloned()
516                .collect::<Vec<_>>(),
517            vec![child.clone(), peer.clone()]
518        );
519        assert_eq!(
520            payload.relations.binding(&BindingId::from("binding")),
521            Some(&CanvasRecordBindingRelation::new("binding", child, group))
522        );
523        assert_eq!(
524            payload
525                .selection
526                .selected_shapes()
527                .cloned()
528                .collect::<Vec<_>>(),
529            vec![ShapeId::from("frame")]
530        );
531        assert!(payload.selection.selected_nodes().next().is_none());
532        assert!(payload.selection.selected_edges().next().is_none());
533    }
534
535    #[test]
536    fn paste_payload_selects_remapped_explicit_roots() {
537        let document = related_tree_document();
538        let mut selection = CanvasSelection::default();
539        selection.insert_shape(ShapeId::from("frame"));
540        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
541
542        let pasted = payload.paste_transaction(&document, point(px(16.0), px(24.0)));
543
544        assert_eq!(
545            pasted
546                .selection
547                .selected_shapes()
548                .cloned()
549                .collect::<Vec<_>>(),
550            vec![ShapeId::from("frame-copy")]
551        );
552        assert!(pasted.selection.selected_nodes().next().is_none());
553        assert!(pasted.selection.selected_edges().next().is_none());
554    }
555
556    #[test]
557    fn copy_selection_filters_payload_selection_to_copied_records() {
558        let mut locked =
559            CanvasNode::new("locked", point(px(40.0), px(0.0)), size(px(10.0), px(10.0)));
560        locked.locked = true;
561        let document = document_fixture()
562            .node(CanvasNode::new(
563                "copyable",
564                point(px(0.0), px(0.0)),
565                size(px(10.0), px(10.0)),
566            ))
567            .node(locked)
568            .build();
569        let mut selection = CanvasSelection::default();
570        selection.insert_node(NodeId::from("copyable"));
571        selection.insert_node(NodeId::from("locked"));
572
573        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
574        let pasted = payload.paste_transaction(&document, point(px(16.0), px(24.0)));
575
576        assert_eq!(
577            payload
578                .nodes
579                .iter()
580                .map(|node| &node.id)
581                .collect::<Vec<_>>(),
582            vec![&NodeId::from("copyable")]
583        );
584        assert_eq!(
585            payload
586                .selection
587                .selected_nodes()
588                .cloned()
589                .collect::<Vec<_>>(),
590            vec![NodeId::from("copyable")]
591        );
592        assert_eq!(
593            pasted
594                .selection
595                .selected_nodes()
596                .cloned()
597                .collect::<Vec<_>>(),
598            vec![NodeId::from("copyable-copy")]
599        );
600    }
601
602    #[test]
603    fn copy_selection_omits_external_relations() {
604        let document = related_document();
605        let mut selection = CanvasSelection::default();
606        selection.insert_node(NodeId::from("child"));
607
608        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
609
610        assert!(payload.relations.is_empty());
611    }
612
613    #[test]
614    fn paste_payload_remaps_internal_relations() {
615        let document = related_document();
616        let mut selection = CanvasSelection::default();
617        selection.insert_node(NodeId::from("child"));
618        selection.insert_shape(ShapeId::from("group"));
619        let payload = CanvasClipboardPayload::from_document_selection(&document, &selection);
620
621        let pasted = payload.paste_transaction(&document, point(px(16.0), px(24.0)));
622
623        let mut draft = document.clone();
624        draft.apply_transaction(pasted.transaction).unwrap();
625
626        let child = CanvasRecordId::Node(NodeId::from("child-copy"));
627        let group = CanvasRecordId::Shape(ShapeId::from("group-copy"));
628        assert_eq!(draft.relations().parent_of(&child), Some(&group));
629        assert_eq!(
630            draft
631                .relations()
632                .members_of(&group)
633                .cloned()
634                .collect::<Vec<_>>(),
635            vec![child.clone()]
636        );
637        assert_eq!(
638            draft.relations().binding(&BindingId::from("binding-copy")),
639            Some(&CanvasRecordBindingRelation::new(
640                "binding-copy",
641                child,
642                group
643            ))
644        );
645    }
646
647    #[test]
648    fn deserializes_clipboard_payload_without_relations() {
649        let payload = serde_json::from_str::<CanvasClipboardPayload>(
650            r#"{"nodes":[],"edges":[],"shapes":[],"selection":{"nodes":[],"edges":[],"shapes":[],"handles":[]}}"#,
651        )
652        .unwrap();
653
654        assert!(payload.relations.is_empty());
655    }
656
657    fn connected_document() -> CanvasDocument {
658        document_fixture()
659            .node(CanvasNode::new(
660                "a",
661                point(px(0.0), px(0.0)),
662                size(px(10.0), px(10.0)),
663            ))
664            .node(CanvasNode::new(
665                "b",
666                point(px(40.0), px(0.0)),
667                size(px(10.0), px(10.0)),
668            ))
669            .edge(CanvasEdge::new(
670                "a-b",
671                CanvasEndpoint::new("a", None::<&str>),
672                CanvasEndpoint::new("b", None::<&str>),
673            ))
674            .build()
675    }
676
677    fn related_document() -> CanvasDocument {
678        let mut document = document_fixture()
679            .node(CanvasNode::new(
680                "child",
681                point(px(0.0), px(0.0)),
682                size(px(10.0), px(10.0)),
683            ))
684            .shape(CanvasShape::new(
685                "group",
686                Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(100.0))),
687            ))
688            .build();
689        document
690            .apply_transaction(CanvasTransaction::new([
691                DocumentCommand::SetRecordParent {
692                    child: CanvasRecordId::Node(NodeId::from("child")),
693                    parent: CanvasRecordId::Shape(ShapeId::from("group")),
694                },
695                DocumentCommand::AddRecordToGroup {
696                    group: CanvasRecordId::Shape(ShapeId::from("group")),
697                    member: CanvasRecordId::Node(NodeId::from("child")),
698                },
699                DocumentCommand::SetRecordBinding(CanvasRecordBindingRelation::new(
700                    "binding",
701                    CanvasRecordId::Node(NodeId::from("child")),
702                    CanvasRecordId::Shape(ShapeId::from("group")),
703                )),
704            ]))
705            .unwrap();
706        document
707    }
708
709    fn related_tree_document() -> CanvasDocument {
710        let mut document = document_fixture()
711            .shape(CanvasShape::new(
712                "frame",
713                Bounds::new(point(px(0.0), px(0.0)), size(px(200.0), px(200.0))),
714            ))
715            .shape(CanvasShape::new(
716                "group",
717                Bounds::new(point(px(20.0), px(20.0)), size(px(100.0), px(100.0))),
718            ))
719            .node(CanvasNode::new(
720                "child",
721                point(px(40.0), px(40.0)),
722                size(px(10.0), px(10.0)),
723            ))
724            .node(CanvasNode::new(
725                "peer",
726                point(px(80.0), px(40.0)),
727                size(px(10.0), px(10.0)),
728            ))
729            .edge(CanvasEdge::new(
730                "child-peer",
731                CanvasEndpoint::new("child", None::<&str>),
732                CanvasEndpoint::new("peer", None::<&str>),
733            ))
734            .build();
735        document
736            .apply_transaction(CanvasTransaction::new([
737                DocumentCommand::SetRecordParent {
738                    child: CanvasRecordId::Shape(ShapeId::from("group")),
739                    parent: CanvasRecordId::Shape(ShapeId::from("frame")),
740                },
741                DocumentCommand::AddRecordToGroup {
742                    group: CanvasRecordId::Shape(ShapeId::from("group")),
743                    member: CanvasRecordId::Node(NodeId::from("child")),
744                },
745                DocumentCommand::AddRecordToGroup {
746                    group: CanvasRecordId::Shape(ShapeId::from("group")),
747                    member: CanvasRecordId::Node(NodeId::from("peer")),
748                },
749                DocumentCommand::SetRecordBinding(CanvasRecordBindingRelation::new(
750                    "binding",
751                    CanvasRecordId::Node(NodeId::from("child")),
752                    CanvasRecordId::Shape(ShapeId::from("group")),
753                )),
754            ]))
755            .unwrap();
756        document
757    }
758}