Skip to main content

blockworx/widget/
clipboard.rs

1//! Copy, cut, and paste, as the drawing surface sees them.
2//!
3//! The payload model itself lives in [`crate::edit::clipboard`] — a value
4//! snapshot of the copied closure with the gesture that made it, versioned
5//! as `Clipboard::V2` and carried over the OS clipboard as JSON. This module is the [`Drawing`] side: which
6//! selection a gesture copied, where a paste lands, and the emitter call each
7//! turns into. Nothing here decides *what* a copy contains — that is the
8//! delete closure's own walk, shared so a cut and a delete cascade agree.
9
10use blockworx_doc::{
11    block_model::Label,
12    commit::Commit,
13    document::{DocIndex, Document},
14    geometry::GridVec,
15    id::{BlockId, PinId},
16    opcode::{Crud, OpCodes},
17};
18use blockworx_geom::Pos2;
19use blockworx_store::doc::DocumentNonce;
20
21use crate::grid::grid_point;
22use crate::{
23    edit::{
24        clipboard::{Clipboard, Paste, PasteTarget, PinPaste, Snapshot},
25        delete::Target,
26        geometry::Shape,
27    },
28    shape::ShapeId,
29    widget::drawing::Drawing,
30};
31
32/// The whole-cell diagonal a targetless paste lands at, so a duplicate never
33/// hides the original it was copied from.
34const UNTARGETED_OFFSET: GridVec = GridVec::new(2, 2);
35
36/// Whether `text` is one of our clipboard payloads rather than plain text to
37/// insert into a focused editor — a copied selection, or a whole exported
38/// document (D19).
39pub fn is_object_clipboard(text: &str) -> bool {
40    Clipboard::from_json(text).is_some() || crate::import::from_clipboard(text).is_some()
41}
42
43/// The entity a copied shape names. An icon has none: it is a value on its
44/// block, and travels with it.
45fn copy_target(id: ShapeId) -> Option<Target> {
46    Some(match id {
47        ShapeId::Rect(id) => Target::Block(id),
48        ShapeId::Port(id) => Target::Pin(id),
49        ShapeId::Text(id) => Target::Text(id),
50        ShapeId::Area(id) => Target::Area(id),
51        ShapeId::Image(id) => Target::Image(id),
52        ShapeId::Icon(_) => return None,
53    })
54}
55
56/// What the tools name a pasted root.
57fn pasted_shape(shape: Shape) -> ShapeId {
58    match shape {
59        Shape::Block(id) => ShapeId::Rect(id),
60        Shape::Port(id) => ShapeId::Port(id),
61        Shape::Text(id) => ShapeId::Text(id),
62        Shape::Area(id) => ShapeId::Area(id),
63        Shape::Image(id) => ShapeId::Image(id),
64        Shape::Icon(id) => ShapeId::Icon(id),
65    }
66}
67
68impl Drawing<'_> {
69    /// The value snapshot of `shapes` — each with the subtree it owns, the
70    /// wires landing on any of their pins, and those wires' labels. `None`
71    /// when the selection holds nothing copyable.
72    pub fn copy_selection(&self, shapes: &[ShapeId]) -> Option<Clipboard> {
73        let targets: Vec<Target> = shapes.iter().filter_map(|&id| copy_target(id)).collect();
74        let clip = crate::edit::clipboard::copy(&self.indexed(), &targets);
75        (!clip.snapshot().is_empty()).then_some(clip)
76    }
77
78    /// The value snapshot of a pin selection — the payload a paste slots onto
79    /// another block's boundary.
80    pub fn copy_pins(&self, pins: &[PinId]) -> Option<Clipboard> {
81        let targets: Vec<Target> = pins.iter().copied().map(Target::Pin).collect();
82        let clip = crate::edit::clipboard::copy(&self.indexed(), &targets);
83        (!clip.snapshot().pins.is_empty()).then(|| clip.into_pins())
84    }
85
86    /// Copy `shapes`, then delete them — one commit, so the cut and the
87    /// cascade it implies undo together. What the delete declines the copy
88    /// declines too, which is what makes the paste that follows a *move*.
89    ///
90    /// `from` is the session document the cut is coming out of: the paste
91    /// that completes it keeps its ids only where it lands back there.
92    pub fn cut_selection(&mut self, shapes: &[ShapeId], from: DocumentNonce) -> Option<Clipboard> {
93        let targets: Vec<Target> = shapes.iter().filter_map(|&id| copy_target(id)).collect();
94        let mut clip = None;
95        self.author("cut_selection", |indexed, sink| {
96            clip = Some(crate::edit::clipboard::cut(indexed, &targets, from, sink));
97        });
98        clip.filter(|clip| !clip.snapshot().is_empty())
99    }
100
101    /// Copy a pin selection, then delete it — the pin half of a cut.
102    pub fn cut_pins(&mut self, pins: &[PinId], from: DocumentNonce) -> Option<Clipboard> {
103        let targets: Vec<Target> = pins.iter().copied().map(Target::Pin).collect();
104        let mut clip = None;
105        self.author("cut_pins", |indexed, sink| {
106            clip = Some(crate::edit::clipboard::cut(indexed, &targets, from, sink));
107        });
108        clip.filter(|clip| !clip.snapshot().pins.is_empty())
109            .map(Clipboard::into_pins)
110    }
111
112    /// Insert `clip` into this scope, returning the roots so the caller can
113    /// select them. A payload this document's own cut made, none of whose
114    /// sources it still holds, is the cut this paste completes and moves in
115    /// place; anything else duplicates.
116    ///
117    /// `into` is the session document being pasted into — a paste into any
118    /// other document mints, however free the ids look.
119    ///
120    /// `target_top_left` lands the group's own top-left corner there; with no
121    /// target the group is offset diagonally so it does not hide its source.
122    pub fn paste_snapshot(
123        &mut self,
124        clip: &Clipboard,
125        into: DocumentNonce,
126        target_top_left: Option<Pos2>,
127    ) -> Vec<ShapeId> {
128        let snapshot = clip.snapshot();
129        let offset = match (target_top_left, snapshot.origin()) {
130            (Some(target), Some(origin)) => {
131                let target = grid_point(target);
132                GridVec::new(target.x - origin.x, target.y - origin.y)
133            }
134            _ => UNTARGETED_OFFSET,
135        };
136        let scope = self.current_scope();
137        let mut ids = self.ids();
138        let mut roots = Vec::new();
139        self.author("paste", |indexed, sink| {
140            roots = crate::edit::clipboard::paste(
141                indexed,
142                Paste {
143                    clip,
144                    into,
145                    target: PasteTarget { scope, offset },
146                },
147                &mut ids,
148                sink,
149            );
150        });
151        roots.into_iter().map(pasted_shape).collect()
152    }
153
154    /// Paste copied pins onto `owner`'s boundary, each at the next free slot,
155    /// growing the block until they fit. A frozen interface takes none.
156    pub fn paste_pins(&mut self, snapshot: &Snapshot, owner: crate::path::Scope) -> Vec<PinId> {
157        let mut ids = self.ids();
158        let mut pasted = Vec::new();
159        self.author("paste_pins", |indexed, sink| {
160            pasted = crate::edit::clipboard::paste_pins(
161                indexed,
162                PinPaste {
163                    pins: &snapshot.pins,
164                    owner,
165                },
166                &mut ids,
167                sink,
168            );
169        });
170        pasted
171    }
172}
173
174/// A whole document as a clipboard payload: everything its **root scope**
175/// holds, with the block `top` sits under titled `title` (D19). Import and
176/// the clipboard paste of an exported document both go through here, so "the
177/// document appears in the active scope" is an ordinary paste — fresh ids,
178/// one gesture, undoable — rather than a second merge path.
179///
180/// The root scope, not `top`'s subtree: `top` names the level the editor
181/// opens on, which is not always the outermost block — a document wrapped
182/// before `top` was re-pointed, or one holding a block drawn beside its
183/// sheet, keeps live blocks outside it, and following `top` would leave them
184/// behind without saying so.
185///
186/// The file is seeded onto an empty document as the commit that creates
187/// it, so an import refuses exactly what the fold refuses.
188pub fn block_from_document(parsed: &Document, title: &str) -> Option<Clipboard> {
189    let mut ops = parsed.creating_commit("Imported a diagram")?.ops().to_vec();
190    retitle_outermost(
191        &mut ops,
192        outermost_ancestor(parsed, parsed.title_block().top),
193        title,
194    );
195    let doc = Document::default()
196        .try_apply(&Commit::new("Imported a diagram".into(), ops))
197        .ok()?;
198    let mut index = DocIndex::of(&doc);
199    let indexed = index.view(&doc);
200    let clip = crate::edit::clipboard::copy(&indexed, &root_contents(&indexed));
201    (!clip.snapshot().is_empty()).then_some(clip)
202}
203
204/// Everything the document root holds, as copy targets. Its own boundary
205/// ports are not among them: they name a boundary the insert has no host
206/// for, so they are reported rather than silently slotted onto whatever
207/// block the paste lands in.
208fn root_contents(indexed: &blockworx_doc::document::IndexedDocument<'_>) -> Vec<Target> {
209    let Some(root) = indexed.index.scope(BlockId::NULL) else {
210        return Vec::new();
211    };
212    if !root.pins.is_empty() {
213        tracing::warn!(
214            "{} boundary port(s) of the inserted diagram have no boundary to join",
215            root.pins.len(),
216        );
217    }
218    root.children
219        .iter()
220        .map(|&id| Target::Block(id))
221        .chain(root.texts.iter().map(|&id| Target::Text(id)))
222        .chain(root.areas.iter().map(|&id| Target::Area(id)))
223        .chain(root.images.iter().map(|&id| Target::Image(id)))
224        .collect()
225}
226
227/// Name the block a reader ends up holding — the outermost ancestor of the
228/// document's `top` — after the document itself. A fresh label rather than
229/// an edit of the one there: a top block whose title was hidden — which a
230/// sheet's usually is — would otherwise arrive as an anonymous rectangle.
231fn retitle_outermost(ops: &mut [OpCodes], outermost: BlockId, title: &str) {
232    for op in ops {
233        if let OpCodes::Block(id, Crud::Create(block)) = op
234            && *id == outermost
235        {
236            block.title = Label {
237                name: title.to_owned(),
238                ..Label::default()
239            };
240        }
241    }
242}
243
244/// The outermost block `id` sits under. Bounded by the block count so a
245/// file naming a parent cycle — which the fold refuses later — terminates.
246fn outermost_ancestor(parsed: &Document, id: BlockId) -> BlockId {
247    let mut current = id;
248    for _ in 0..parsed.blocks().count() {
249        match parsed.block(&current).map(|block| block.parent) {
250            Some(parent) if parent != BlockId::NULL => current = parent,
251            _ => break,
252        }
253    }
254    current
255}