diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 075fdf4..48238f1 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2258,6 +2258,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flacenc" version = "0.4.0" @@ -3443,6 +3449,9 @@ dependencies = [ "image", "kurbo 0.12.0", "lru", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "pathdiff", "rstar", "serde", @@ -3451,6 +3460,9 @@ dependencies = [ "uuid", "vello", "wgpu", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11-clipboard", "zip", ] @@ -4030,7 +4042,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.110", @@ -4080,10 +4092,10 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -4094,10 +4106,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", "block2 0.6.2", + "libc", "objc2 0.6.3", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -4138,6 +4157,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-contacts" version = "0.2.2" @@ -4184,6 +4214,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4222,6 +4263,16 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.2.2" @@ -4234,6 +4285,31 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4314,6 +4390,17 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -4333,13 +4420,13 @@ dependencies = [ "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -4415,6 +4502,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "outref" version = "0.1.0" @@ -4593,6 +4690,17 @@ dependencies = [ "indexmap 2.12.0", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.12.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -6414,6 +6522,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph 0.8.3", +] + [[package]] name = "ttf-parser" version = "0.21.1" @@ -7711,6 +7830,24 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.2", + "thiserror 2.0.17", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7736,6 +7873,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 015614f..a763e7b 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -47,5 +47,24 @@ rstar = "0.12" # System clipboard arboard = "3" +# ── Temporary: platform-native custom MIME type clipboard ───────────────────── +# These deps exist because arboard does not yet support custom MIME types. +# Remove once arboard gains that feature (https://github.com/1Password/arboard/issues/14). +[target.'cfg(target_os = "linux")'.dependencies] +wl-clipboard-rs = "0.9" +x11-clipboard = "0.9" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] } +objc2-foundation = { version = "0.3", features = ["NSString", "NSData"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.60", features = [ + "Win32_Foundation", + "Win32_System_DataExchange", + "Win32_System_Memory", +] } + [dev-dependencies] tiny-skia = "0.11" diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index db85f1a..f1a20c3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -1,16 +1,48 @@ -//! Clipboard management for cut/copy/paste operations +//! Clipboard management for cut/copy/paste operations. //! -//! Supports multiple content types (clip instances, shapes) with -//! cross-platform clipboard integration via arboard. +//! # Content types +//! [`ClipboardContent`] covers every selectable item in the app: +//! - [`ClipInstances`](ClipboardContent::ClipInstances) — timeline clips +//! - [`VectorGeometry`](ClipboardContent::VectorGeometry) — DCEL shapes (stub; Phase 2) +//! - [`MidiNotes`](ClipboardContent::MidiNotes) — piano-roll notes +//! - [`RasterPixels`](ClipboardContent::RasterPixels) — raster selection +//! - [`Layers`](ClipboardContent::Layers) — complete layer subtrees +//! - [`AudioNodes`](ClipboardContent::AudioNodes) — audio-graph node subgraph +//! +//! # Storage strategy +//! Content is kept in three places simultaneously: +//! 1. **Internal** (`self.internal`) — in-process, zero-copy, always preferred. +//! 2. **Platform custom type** (`application/x-lightningbeam`) via +//! [`crate::clipboard_platform`] — enables cross-process paste between LB windows. +//! 3. **arboard text fallback** — `LIGHTNINGBEAM_CLIPBOARD:` in the system +//! text clipboard for maximum compatibility (e.g. terminals, remote desktops). +//! +//! For `RasterPixels` an additional `image/png` entry is set on macOS and Windows +//! so the image can be pasted into external apps. +//! +//! # Temporary note +//! The custom-MIME platform layer ([`crate::clipboard_platform`]) is a shim until +//! arboard supports custom MIME types natively +//! (). When that lands, remove +//! `clipboard_platform`, the `objc2*` and `windows-sys` Cargo deps, and call +//! arboard directly. use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip}; use crate::layer::{AudioLayerType, AnyLayer}; -use crate::shape::Shape; +use crate::clipboard_platform; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; -/// Layer type tag for clipboard, so paste knows where clips can go +/// MIME type used for cross-process Lightningbeam clipboard data. +pub const LIGHTNINGBEAM_MIME: &str = "application/x-lightningbeam"; + +/// JSON text-clipboard prefix (arboard fallback). +const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:"; + +// ─────────────────────────────── Layer type tag ───────────────────────────── + +/// Layer type tag for clipboard — tells paste where clip instances can go. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ClipboardLayerType { Vector, @@ -21,7 +53,7 @@ pub enum ClipboardLayerType { } impl ClipboardLayerType { - /// Determine the clipboard layer type from a document layer + /// Determine the clipboard layer type from a document layer. pub fn from_layer(layer: &AnyLayer) -> Self { match layer { AnyLayer::Vector(_) => ClipboardLayerType::Vector, @@ -31,12 +63,12 @@ impl ClipboardLayerType { AudioLayerType::Midi => ClipboardLayerType::AudioMidi, }, AnyLayer::Effect(_) => ClipboardLayerType::Effect, - AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector - AnyLayer::Raster(_) => ClipboardLayerType::Vector, // Raster layers treated as vector for clipboard purposes + AnyLayer::Group(_) => ClipboardLayerType::Vector, + AnyLayer::Raster(_) => ClipboardLayerType::Vector, } } - /// Check if a layer is compatible with this clipboard layer type + /// Check if a layer is compatible with this clipboard layer type. pub fn is_compatible(&self, layer: &AnyLayer) -> bool { match (self, layer) { (ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true, @@ -53,52 +85,122 @@ impl ClipboardLayerType { } } -/// Content stored in the clipboard +// ──────────────────────────── Shared clip bundle ───────────────────────────── + +/// Clip definitions referenced by clipboard content. +/// +/// Shared between [`ClipboardContent::ClipInstances`] and [`ClipboardContent::Layers`]. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ReferencedClips { + pub audio_clips: Vec<(Uuid, AudioClip)>, + pub video_clips: Vec<(Uuid, VideoClip)>, + pub vector_clips: Vec<(Uuid, VectorClip)>, + pub image_assets: Vec<(Uuid, ImageAsset)>, +} + +// ───────────────────────────── Clipboard content ───────────────────────────── + +/// Content stored in the clipboard. +/// +/// The `serde(tag = "type")` discriminant is stable — unknown variants +/// deserialize as `None`, so new variants can be added without breaking +/// existing serialized data. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClipboardContent { - /// Clip instances with their referenced clip definitions + /// Timeline clip instances with all referenced clip definitions. ClipInstances { - /// Source layer type (for paste compatibility) + /// Source layer type (for paste compatibility). layer_type: ClipboardLayerType, - /// The clip instances (IDs will be regenerated on paste) + /// The clip instances (IDs regenerated on paste). instances: Vec, - /// Referenced audio clip definitions + /// Referenced audio clip definitions. audio_clips: Vec<(Uuid, AudioClip)>, - /// Referenced video clip definitions + /// Referenced video clip definitions. video_clips: Vec<(Uuid, VideoClip)>, - /// Referenced vector clip definitions + /// Referenced vector clip definitions. vector_clips: Vec<(Uuid, VectorClip)>, - /// Referenced image assets + /// Referenced image assets. image_assets: Vec<(Uuid, ImageAsset)>, }, - /// Shapes from a vector layer's keyframe - Shapes { - /// Shapes (with embedded transforms) - shapes: Vec, + + /// Selected DCEL geometry from a vector layer. + /// + /// Currently a stub — `data` is opaque bytes whose format is TBD in Phase 2 + /// once DCEL serialization is implemented. Copy/paste of vector shapes does + /// nothing until then. Secondary formats (`image/svg+xml`, `image/png`) are + /// also deferred to Phase 2. + VectorGeometry { + /// Opaque DCEL subgraph bytes (format TBD, Phase 2). + data: Vec, }, - /// MIDI notes from the piano roll + + /// MIDI notes from the piano roll. MidiNotes { - /// Notes: (start_time, note, velocity, duration) — times relative to selection start + /// `(start_time, note, velocity, duration)` — times relative to selection start. notes: Vec<(f64, u8, u8, f64)>, }, - /// Raw pixel data from a raster layer selection. + + /// Raw pixel region from a raster layer selection. + /// /// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes — /// the same in-memory format as `RasterKeyframe::raw_pixels`. + /// + /// On macOS and Windows an `image/png` secondary format is also set so the + /// content can be pasted into external apps. RasterPixels { pixels: Vec, width: u32, height: u32, }, + + /// One or more complete layers copied from the timeline. + /// + /// [`AnyLayer`] derives `Serialize`/`Deserialize`; only + /// `RasterKeyframe::raw_pixels` is excluded from serde (`#[serde(skip)]`) and + /// is therefore carried separately in `raster_pixels`. + /// + /// On paste: insert as sibling layers at the current selection point with all + /// UUIDs regenerated. + Layers { + /// Complete serialized layer trees (raw_pixels absent). + layers: Vec, + /// Raster pixel data keyed by `(layer_id, time.to_bits())`. + /// Restored into `RasterKeyframe::raw_pixels` after deserialization by + /// matching layer_id + time_bits against the deserialized keyframes. + raster_pixels: Vec<(Uuid, u64, Vec)>, + /// All clip definitions referenced by any of the copied layers. + referenced_clips: ReferencedClips, + }, + + /// Selected nodes and edges from an audio effect/synthesis graph. + /// + /// Uses the same serialization types as preset save/load + /// (`daw_backend::audio::node_graph::preset`). + /// + /// On paste: add nodes to the target layer's graph with new IDs, then sync + /// to the DAW backend (same pattern as `ClipInstances`). + AudioNodes { + /// Selected nodes. + nodes: Vec, + /// Connections between the selected nodes only. + connections: Vec, + /// Source layer UUID — hint for paste target validation. + source_layer_id: Uuid, + }, } +// ──────────────────────── ID regeneration ─────────────────────────────────── + impl ClipboardContent { - /// Create a clone of this content with all UUIDs regenerated - /// Returns the new content and a mapping from old to new IDs + /// Clone this content with all UUIDs regenerated. + /// + /// Returns the new content and a mapping from old → new IDs. pub fn with_regenerated_ids(&self) -> (Self, HashMap) { - let mut id_map = HashMap::new(); + let mut id_map: HashMap = HashMap::new(); match self { + // ── ClipInstances ─────────────────────────────────────────────── ClipboardContent::ClipInstances { layer_type, instances, @@ -107,67 +209,11 @@ impl ClipboardContent { vector_clips, image_assets, } => { - // Regenerate clip definition IDs - let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_clips - .iter() - .map(|(old_id, clip)| { - let new_id = Uuid::new_v4(); - id_map.insert(*old_id, new_id); - let mut new_clip = clip.clone(); - new_clip.id = new_id; - (new_id, new_clip) - }) - .collect(); - - let new_video_clips: Vec<(Uuid, VideoClip)> = video_clips - .iter() - .map(|(old_id, clip)| { - let new_id = Uuid::new_v4(); - id_map.insert(*old_id, new_id); - let mut new_clip = clip.clone(); - new_clip.id = new_id; - (new_id, new_clip) - }) - .collect(); - - let new_vector_clips: Vec<(Uuid, VectorClip)> = vector_clips - .iter() - .map(|(old_id, clip)| { - let new_id = Uuid::new_v4(); - id_map.insert(*old_id, new_id); - let mut new_clip = clip.clone(); - new_clip.id = new_id; - (new_id, new_clip) - }) - .collect(); - - let new_image_assets: Vec<(Uuid, ImageAsset)> = image_assets - .iter() - .map(|(old_id, asset)| { - let new_id = Uuid::new_v4(); - id_map.insert(*old_id, new_id); - let mut new_asset = asset.clone(); - new_asset.id = new_id; - (new_id, new_asset) - }) - .collect(); - - // Regenerate clip instance IDs and remap clip_id references - let new_instances: Vec = instances - .iter() - .map(|inst| { - let new_instance_id = Uuid::new_v4(); - id_map.insert(inst.id, new_instance_id); - let mut new_inst = inst.clone(); - new_inst.id = new_instance_id; - // Remap clip_id to new definition ID - if let Some(new_clip_id) = id_map.get(&inst.clip_id) { - new_inst.clip_id = *new_clip_id; - } - new_inst - }) - .collect(); - + let new_audio_clips = regen_audio_clips(audio_clips, &mut id_map); + let new_video_clips = regen_video_clips(video_clips, &mut id_map); + let new_vector_clips = regen_vector_clips(vector_clips, &mut id_map); + let new_image_assets = regen_image_assets(image_assets, &mut id_map); + let new_instances = regen_clip_instances(instances, &mut id_map); ( ClipboardContent::ClipInstances { layer_type: layer_type.clone(), @@ -180,29 +226,82 @@ impl ClipboardContent { id_map, ) } + + // ── VectorGeometry ────────────────────────────────────────────── + // TODO (Phase 2): remap DCEL vertex/edge UUIDs once DCEL serialization + // is defined. + ClipboardContent::VectorGeometry { data } => { + (ClipboardContent::VectorGeometry { data: data.clone() }, id_map) + } + + // ── MidiNotes ─────────────────────────────────────────────────── ClipboardContent::MidiNotes { notes } => { - // No IDs to regenerate, just clone (ClipboardContent::MidiNotes { notes: notes.clone() }, id_map) } - ClipboardContent::RasterPixels { pixels, width, height } => { - (ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map) - } - ClipboardContent::Shapes { shapes } => { - // Regenerate shape IDs - let new_shapes: Vec = shapes + + // ── RasterPixels ──────────────────────────────────────────────── + ClipboardContent::RasterPixels { pixels, width, height } => ( + ClipboardContent::RasterPixels { + pixels: pixels.clone(), + width: *width, + height: *height, + }, + id_map, + ), + + // ── Layers ────────────────────────────────────────────────────── + ClipboardContent::Layers { layers, raster_pixels, referenced_clips } => { + let new_clips = regen_referenced_clips(referenced_clips, &mut id_map); + let new_layers: Vec = layers .iter() - .map(|shape| { - let new_id = Uuid::new_v4(); - id_map.insert(shape.id, new_id); - let mut new_shape = shape.clone(); - new_shape.id = new_id; - new_shape + .map(|l| regen_any_layer(l, &mut id_map)) + .collect(); + // Remap raster_pixels layer_id keys. + let new_raster: Vec<(Uuid, u64, Vec)> = raster_pixels + .iter() + .map(|(old_lid, time_bits, px)| { + let new_lid = id_map.get(old_lid).copied().unwrap_or(*old_lid); + (new_lid, *time_bits, px.clone()) }) .collect(); - ( - ClipboardContent::Shapes { - shapes: new_shapes, + ClipboardContent::Layers { + layers: new_layers, + raster_pixels: new_raster, + referenced_clips: new_clips, + }, + id_map, + ) + } + + // ── AudioNodes ────────────────────────────────────────────────── + ClipboardContent::AudioNodes { nodes, connections, source_layer_id } => { + // Remap u32 node IDs. + let mut node_id_map: HashMap = HashMap::new(); + let new_nodes: Vec = nodes + .iter() + .map(|n| { + let new_id = node_id_map.len() as u32 + 1; + node_id_map.insert(n.id, new_id); + let mut nn = n.clone(); + nn.id = new_id; + nn + }) + .collect(); + let new_connections: Vec = connections + .iter() + .map(|c| { + let mut nc = c.clone(); + nc.from_node = node_id_map.get(&c.from_node).copied().unwrap_or(c.from_node); + nc.to_node = node_id_map.get(&c.to_node).copied().unwrap_or(c.to_node); + nc + }) + .collect(); + ( + ClipboardContent::AudioNodes { + nodes: new_nodes, + connections: new_connections, + source_layer_id: *source_layer_id, }, id_map, ) @@ -211,52 +310,281 @@ impl ClipboardContent { } } -/// JSON prefix for clipboard text to identify Lightningbeam content -const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:"; +// ──────────────────────── ID regeneration helpers ─────────────────────────── -/// Manages clipboard operations with internal + system clipboard +fn regen_audio_clips( + clips: &[(Uuid, AudioClip)], + id_map: &mut HashMap, +) -> Vec<(Uuid, AudioClip)> { + clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut c = clip.clone(); + c.id = new_id; + (new_id, c) + }) + .collect() +} + +fn regen_video_clips( + clips: &[(Uuid, crate::clip::VideoClip)], + id_map: &mut HashMap, +) -> Vec<(Uuid, crate::clip::VideoClip)> { + clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut c = clip.clone(); + c.id = new_id; + (new_id, c) + }) + .collect() +} + +fn regen_vector_clips( + clips: &[(Uuid, VectorClip)], + id_map: &mut HashMap, +) -> Vec<(Uuid, VectorClip)> { + clips + .iter() + .map(|(old_id, clip)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut c = clip.clone(); + c.id = new_id; + (new_id, c) + }) + .collect() +} + +fn regen_image_assets( + assets: &[(Uuid, ImageAsset)], + id_map: &mut HashMap, +) -> Vec<(Uuid, ImageAsset)> { + assets + .iter() + .map(|(old_id, asset)| { + let new_id = Uuid::new_v4(); + id_map.insert(*old_id, new_id); + let mut a = asset.clone(); + a.id = new_id; + (new_id, a) + }) + .collect() +} + +fn regen_clip_instances( + instances: &[ClipInstance], + id_map: &mut HashMap, +) -> Vec { + instances + .iter() + .map(|inst| { + let new_id = Uuid::new_v4(); + id_map.insert(inst.id, new_id); + let mut i = inst.clone(); + i.id = new_id; + if let Some(new_clip_id) = id_map.get(&inst.clip_id) { + i.clip_id = *new_clip_id; + } + i + }) + .collect() +} + +fn regen_referenced_clips( + rc: &ReferencedClips, + id_map: &mut HashMap, +) -> ReferencedClips { + ReferencedClips { + audio_clips: regen_audio_clips(&rc.audio_clips, id_map), + video_clips: regen_video_clips(&rc.video_clips, id_map), + vector_clips: regen_vector_clips(&rc.vector_clips, id_map), + image_assets: regen_image_assets(&rc.image_assets, id_map), + } +} + +/// Regenerate the layer's own ID (and all descendant IDs for group layers). +fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap) -> AnyLayer { + match layer { + AnyLayer::Vector(vl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(vl.layer.id, new_layer_id); + let mut nl = vl.clone(); + nl.layer.id = new_layer_id; + nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map); + AnyLayer::Vector(nl) + } + AnyLayer::Audio(al) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(al.layer.id, new_layer_id); + let mut nl = al.clone(); + nl.layer.id = new_layer_id; + nl.clip_instances = regen_clip_instances(&al.clip_instances, id_map); + AnyLayer::Audio(nl) + } + AnyLayer::Video(vl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(vl.layer.id, new_layer_id); + let mut nl = vl.clone(); + nl.layer.id = new_layer_id; + nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map); + AnyLayer::Video(nl) + } + AnyLayer::Effect(el) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(el.layer.id, new_layer_id); + let mut nl = el.clone(); + nl.layer.id = new_layer_id; + nl.clip_instances = regen_clip_instances(&el.clip_instances, id_map); + AnyLayer::Effect(nl) + } + AnyLayer::Raster(rl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(rl.layer.id, new_layer_id); + let mut nl = rl.clone(); + nl.layer.id = new_layer_id; + AnyLayer::Raster(nl) + } + AnyLayer::Group(gl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(gl.layer.id, new_layer_id); + let mut nl = gl.clone(); + nl.layer.id = new_layer_id; + nl.children = gl.children.iter().map(|c| regen_any_layer(c, id_map)).collect(); + AnyLayer::Group(nl) + } + } +} + +// ──────────────────────────── PNG encoding helper ──────────────────────────── + +/// Encode sRGB premultiplied RGBA pixels as PNG bytes. +/// +/// Returns `None` on encoding failure (logged to stderr). +pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Option> { + use image::RgbaImage; + // Un-premultiply before encoding (same as try_set_raster_image). + let straight: Vec = pixels + .chunks_exact(4) + .flat_map(|p| { + let a = p[3]; + if a == 0 { + [0u8, 0, 0, 0] + } else { + let inv = 255.0 / a as f32; + [ + (p[0] as f32 * inv).round().min(255.0) as u8, + (p[1] as f32 * inv).round().min(255.0) as u8, + (p[2] as f32 * inv).round().min(255.0) as u8, + a, + ] + } + }) + .collect(); + let img = RgbaImage::from_raw(width, height, straight)?; + match crate::brush_engine::encode_png(&img) { + Ok(bytes) => Some(bytes), + Err(e) => { + eprintln!("clipboard: PNG encode failed: {e}"); + None + } + } +} + +// ───────────────────────────── ClipboardManager ───────────────────────────── + +/// Manages clipboard operations with internal + system clipboard. pub struct ClipboardManager { - /// Internal clipboard (preserves rich data without serialization loss) + /// Internal clipboard (preserves rich data without serialization loss). internal: Option, - /// System clipboard handle (lazy-initialized) + /// System clipboard handle (lazy-initialized). system: Option, } impl ClipboardManager { - /// Create a new clipboard manager + /// Create a new clipboard manager. pub fn new() -> Self { let system = arboard::Clipboard::new().ok(); - Self { - internal: None, - system, - } + Self { internal: None, system } } - /// Copy content to both internal and system clipboard + /// Copy content to the internal clipboard, the platform custom-MIME clipboard, + /// and the arboard text-fallback clipboard. pub fn copy(&mut self, content: ClipboardContent) { - // Serialize to system clipboard as JSON text - if let Some(system) = self.system.as_mut() { - if let Ok(json) = serde_json::to_string(&content) { - let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json); - let _ = system.set_text(clipboard_text); + let json = serde_json::to_string(&content).unwrap_or_default(); + + // Build platform entries (custom MIME always present; PNG secondary for raster). + let mut entries: Vec<(&str, Vec)> = + vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())]; + if let ClipboardContent::RasterPixels { pixels, width, height } = &content { + if let Some(png) = encode_raster_as_png(pixels, *width, *height) { + entries.push(("image/png", png)); } } - // Store internally for rich access + // Ordering note: on macOS/Windows arboard must go first because set_text() + // calls clearContents/EmptyClipboard, after which clipboard_platform::set() + // appends the custom types. On Linux the order is reversed so that arboard + // ends up as the final clipboard owner with text/plain available — egui reads + // text/plain to generate Event::Paste for Ctrl+V. + // + // try_set_raster_image() is intentionally omitted: it calls arboard.set_image() + // which calls clearContents again, wiping the text and custom types we just set. + // The image/png entry in `entries` covers external-app image interop instead. + + #[cfg(target_os = "linux")] + { + // Linux: platform first, then arboard.set_text() becomes the final owner. + clipboard_platform::set( + &entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::>(), + ); + if let Some(sys) = self.system.as_mut() { + let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json)); + } + } + + #[cfg(not(target_os = "linux"))] + { + // macOS/Windows: arboard first (clears clipboard), then append custom types. + if let Some(sys) = self.system.as_mut() { + let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json)); + } + clipboard_platform::set( + &entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::>(), + ); + } + self.internal = Some(content); } - /// Try to paste content - /// Returns internal clipboard if available, falls back to system clipboard JSON + /// Try to paste content. + /// + /// Priority: + /// 1. Internal cache (same-process fast path). + /// 2. Platform custom MIME type (cross-process LB → LB). + /// 3. arboard text fallback (terminals, remote desktops, older LB builds). pub fn paste(&mut self) -> Option { - // Try internal clipboard first + // 1. Internal cache. if let Some(content) = &self.internal { return Some(content.clone()); } - // Fall back to system clipboard - if let Some(system) = self.system.as_mut() { - if let Ok(text) = system.get_text() { + // 2. Platform custom MIME type. + if let Some((_, data)) = clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) { + if let Ok(s) = std::str::from_utf8(&data) { + if let Ok(content) = serde_json::from_str::(s) { + return Some(content); + } + } + } + + // 3. arboard text fallback. + if let Some(sys) = self.system.as_mut() { + if let Ok(text) = sys.get_text() { if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) { if let Ok(content) = serde_json::from_str::(json) { return Some(content); @@ -271,25 +599,26 @@ impl ClipboardManager { /// Copy raster pixels to the system clipboard as an image. /// /// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes). - /// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors - /// (arboard is a temporary integration point and will be replaced). + /// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors. pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) { let Some(system) = self.system.as_mut() else { return }; - // Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard. - let straight: Vec = pixels.chunks_exact(4).flat_map(|p| { - let a = p[3]; - if a == 0 { - [0u8, 0, 0, 0] - } else { - let inv = 255.0 / a as f32; - [ - (p[0] as f32 * inv).round().min(255.0) as u8, - (p[1] as f32 * inv).round().min(255.0) as u8, - (p[2] as f32 * inv).round().min(255.0) as u8, - a, - ] - } - }).collect(); + let straight: Vec = pixels + .chunks_exact(4) + .flat_map(|p| { + let a = p[3]; + if a == 0 { + [0u8, 0, 0, 0] + } else { + let inv = 255.0 / a as f32; + [ + (p[0] as f32 * inv).round().min(255.0) as u8, + (p[1] as f32 * inv).round().min(255.0) as u8, + (p[2] as f32 * inv).round().min(255.0) as u8, + a, + ] + } + }) + .collect(); let img = arboard::ImageData { width: width as usize, height: height as usize, @@ -306,36 +635,37 @@ impl ClipboardManager { let img = self.system.as_mut()?.get_image().ok()?; let width = img.width as u32; let height = img.height as u32; - // Premultiply: straight RGBA8 → sRGB-premul. - let premul: Vec = img.bytes.chunks_exact(4).flat_map(|p| { - let a = p[3]; - if a == 0 { - [0u8, 0, 0, 0] - } else { - let scale = a as f32 / 255.0; - [ - (p[0] as f32 * scale).round() as u8, - (p[1] as f32 * scale).round() as u8, - (p[2] as f32 * scale).round() as u8, - a, - ] - } - }).collect(); + let premul: Vec = img + .bytes + .chunks_exact(4) + .flat_map(|p| { + let a = p[3]; + if a == 0 { + [0u8, 0, 0, 0] + } else { + let scale = a as f32 / 255.0; + [ + (p[0] as f32 * scale).round() as u8, + (p[1] as f32 * scale).round() as u8, + (p[2] as f32 * scale).round() as u8, + a, + ] + } + }) + .collect(); Some((premul, width, height)) } - /// Check if there's content available to paste + /// Check if there is content available to paste. pub fn has_content(&mut self) -> bool { if self.internal.is_some() { return true; } - - if let Some(system) = self.system.as_mut() { - if let Ok(text) = system.get_text() { + if let Some(sys) = self.system.as_mut() { + if let Ok(text) = sys.get_text() { return text.starts_with(CLIPBOARD_PREFIX); } } - false } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard_platform.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard_platform.rs new file mode 100644 index 0000000..0de7172 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard_platform.rs @@ -0,0 +1,310 @@ +//! Platform-native clipboard integration for custom MIME types. +//! +//! > **Temporary shim** — this module exists because arboard does not yet support +//! > custom MIME types. Once arboard gains that capability (tracked at +//! > ) this module and all +//! > platform-conditional deps (`objc2*`, `windows-sys`, `wl-clipboard-rs`, +//! > `x11-clipboard`) should be removed and replaced with a single arboard call. +//! +//! Provides [`set`] and [`get`] functions for reading and writing non-text +//! clipboard formats directly via each platform's native clipboard API. +//! +//! # Platform notes +//! - **macOS**: NSPasteboard via objc2 — appends entries to the clipboard that +//! arboard already opened; must be called *after* `arboard::set_text()` / +//! `set_image()` since arboard calls `clearContents` internally. +//! - **Windows**: `RegisterClipboardFormat` + `SetClipboardData` — appends to +//! the clipboard arboard already populated; must be called *after* arboard. +//! - **Linux/Wayland**: `wl-clipboard-rs` — creates its own Wayland connection +//! and spawns a background thread to serve clipboard requests; no external +//! tools required. +//! - **Linux/X11**: `x11-clipboard` — serves custom-atom requests via its +//! background thread; only the first entry is set (X11 single-target +//! limitation per selection). + +/// Set one or more `(mime_type, data)` pairs on the platform clipboard. +/// +/// On macOS and Windows this must be called **after** `arboard::Clipboard::set_text()` / +/// `set_image()` because arboard empties the clipboard first. +/// +/// On Linux/X11 only the first entry is used. +pub fn set(entries: &[(&str, &[u8])]) { + platform_impl::set(entries); +} + +/// Return the first available `(mime_type, data)` pair from the platform clipboard. +/// +/// `preferred` is tried in order; the first MIME type with data wins. +/// Returns `None` when none of the requested types are present. +pub fn get(preferred: &[&str]) -> Option<(String, Vec)> { + platform_impl::get(preferred) +} + +// ─────────────────────────────────── macOS ────────────────────────────────── + +#[cfg(target_os = "macos")] +mod platform_impl { + use objc2::rc::Retained; + use objc2_app_kit::NSPasteboard; + use objc2_foundation::{NSData, NSString}; + + pub fn set(entries: &[(&str, &[u8])]) { + // SAFETY: must be called from the main thread (same as ClipboardManager). + unsafe { + let pb = NSPasteboard::generalPasteboard(); + for &(mime, data) in entries { + let ns_type: Retained = NSString::from_str(mime); + let ns_data: Retained = NSData::with_bytes(data); + // setData:forType: appends to the current clipboard contents + // (arboard already called clearContents, so no double-clear needed). + pb.setData_forType(&ns_data, &ns_type); + } + } + } + + pub fn get(preferred: &[&str]) -> Option<(String, Vec)> { + // SAFETY: must be called from the main thread. + unsafe { + let pb = NSPasteboard::generalPasteboard(); + for &mime in preferred { + let ns_type: Retained = NSString::from_str(mime); + if let Some(ns_data) = pb.dataForType(&ns_type) { + // NSData implements AsRef<[u8]> in objc2-foundation. + let bytes = ns_data.as_ref().to_vec(); + return Some((mime.to_string(), bytes)); + } + } + } + None + } +} + +// ─────────────────────────────────── Windows ──────────────────────────────── + +#[cfg(target_os = "windows")] +mod platform_impl { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, + }; + use windows_sys::Win32::System::Memory::{ + GlobalAlloc, GlobalFree, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, + }; + + static FORMAT_IDS: OnceLock>> = OnceLock::new(); + + /// Register (or look up) the clipboard format ID for a MIME-type string. + fn registered_format(mime: &str) -> u32 { + let ids = FORMAT_IDS.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = ids.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(&id) = guard.get(mime) { + return id; + } + // RegisterClipboardFormatW requires a null-terminated UTF-16 string. + let wide: Vec = mime.encode_utf16().chain(std::iter::once(0)).collect(); + let id = unsafe { RegisterClipboardFormatW(wide.as_ptr()) }; + guard.insert(mime.to_string(), id); + id + } + + pub fn set(entries: &[(&str, &[u8])]) { + // arboard already called EmptyClipboard; we just append new formats. + // OpenClipboard(NULL) acquires ownership without clearing. + unsafe { + if OpenClipboard(std::ptr::null_mut()) == 0 { + return; + } + for &(mime, data) in entries { + let fmt = registered_format(mime); + let h = GlobalAlloc(GMEM_MOVEABLE, data.len()); + if h.is_null() { + continue; + } + let ptr = GlobalLock(h); + if ptr.is_null() { + GlobalFree(h); + continue; + } + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as *mut u8, data.len()); + GlobalUnlock(h); + SetClipboardData(fmt, h as HANDLE); + } + CloseClipboard(); + } + } + + pub fn get(preferred: &[&str]) -> Option<(String, Vec)> { + unsafe { + if OpenClipboard(std::ptr::null_mut()) == 0 { + return None; + } + let mut result = None; + for &mime in preferred { + let fmt = registered_format(mime); + let h = GetClipboardData(fmt); + if h.is_null() { + continue; + } + let ptr = GlobalLock(h as _); + if ptr.is_null() { + continue; + } + let size = GlobalSize(h as _); + let data = std::slice::from_raw_parts(ptr as *const u8, size).to_vec(); + GlobalUnlock(h as _); + result = Some((mime.to_string(), data)); + break; + } + CloseClipboard(); + result + } + } +} + +// ─────────────────────────────────── Linux ────────────────────────────────── + +#[cfg(target_os = "linux")] +mod platform_impl { + pub fn set(entries: &[(&str, &[u8])]) { + if std::env::var("WAYLAND_DISPLAY").is_ok() { + set_wayland(entries); + } else { + set_x11(entries); + } + } + + pub fn get(preferred: &[&str]) -> Option<(String, Vec)> { + if std::env::var("WAYLAND_DISPLAY").is_ok() { + get_wayland(preferred) + } else { + get_x11(preferred) + } + } + + // ── Wayland ────────────────────────────────────────────────────────────── + + fn set_wayland(entries: &[(&str, &[u8])]) { + use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source}; + + let sources: Vec = entries + .iter() + .map(|&(mime, data)| MimeSource { + source: Source::Bytes(data.to_vec().into_boxed_slice()), + mime_type: MimeType::Specific(mime.to_string()), + }) + .collect(); + + // copy_multi spawns a background thread that serves clipboard requests + // until another client takes ownership — no blocking, no subprocess needed. + if let Err(e) = Options::new().copy_multi(sources) { + eprintln!("[clipboard_platform] wl-clipboard-rs set error: {e}"); + } + } + + fn get_wayland(preferred: &[&str]) -> Option<(String, Vec)> { + use std::io::Read; + use wl_clipboard_rs::paste::{self, ClipboardType, Error, MimeType, Seat}; + + for &mime in preferred { + match paste::get_contents( + ClipboardType::Regular, + Seat::Unspecified, + MimeType::Specific(mime), + ) { + Ok((mut pipe, _)) => { + let mut buf = Vec::new(); + if pipe.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + return Some((mime.to_string(), buf)); + } + } + // These are non-error "not present" conditions — try the next type. + Err(Error::ClipboardEmpty) | Err(Error::NoMimeType) => continue, + Err(e) => { + eprintln!("[clipboard_platform] wl-clipboard-rs get error for {mime}: {e}"); + continue; + } + } + } + None + } + + // ── X11 ────────────────────────────────────────────────────────────────── + + use std::sync::Mutex; + use std::time::Duration; + + /// Keeps the x11-clipboard instance alive so its background thread can + /// continue serving SelectionRequest events. Replaced on each `set_x11` call. + static X11_CB: Mutex> = Mutex::new(None); + + fn set_x11(entries: &[(&str, &[u8])]) { + // X11 clipboard can only serve one target atom per selection owner. + // Use the first entry (the custom LB MIME type). + let Some(&(mime, data)) = entries.first() else { return }; + + let cb = match x11_clipboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + eprintln!("[clipboard_platform] x11-clipboard init error: {e}"); + return; + } + }; + + let atom = match cb.setter.get_atom(mime) { + Ok(a) => a, + Err(e) => { + eprintln!("[clipboard_platform] x11-clipboard intern atom error for {mime}: {e}"); + return; + } + }; + + if let Err(e) = cb.store(cb.setter.atoms.clipboard, atom, data.to_vec()) { + eprintln!("[clipboard_platform] x11-clipboard store error: {e}"); + return; + } + + // Keep alive to serve requests; replacing drops the previous instance. + *X11_CB.lock().unwrap_or_else(|e| e.into_inner()) = Some(cb); + } + + fn get_x11(preferred: &[&str]) -> Option<(String, Vec)> { + let cb = match x11_clipboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + eprintln!("[clipboard_platform] x11-clipboard init error: {e}"); + return None; + } + }; + + for &mime in preferred { + let atom = match cb.getter.get_atom(mime) { + Ok(a) => a, + Err(_) => continue, + }; + + match cb.load( + cb.getter.atoms.clipboard, + atom, + cb.getter.atoms.property, + Duration::from_secs(1), + ) { + Ok(data) if !data.is_empty() => return Some((mime.to_string(), data)), + _ => continue, + } + } + None + } +} + +// ──────────────────────────── Fallback (other OS) ─────────────────────────── + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +mod platform_impl { + pub fn set(_entries: &[(&str, &[u8])]) {} + pub fn get(_preferred: &[&str]) -> Option<(String, Vec)> { + None + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 40e705b..858bc07 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -43,6 +43,7 @@ pub mod file_types; pub mod file_io; pub mod export; pub mod clipboard; +pub(crate) mod clipboard_platform; pub mod region_select; pub mod dcel2; pub use dcel2 as dcel; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 10d2abc..558fb96 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1973,7 +1973,6 @@ impl EditorApp { let (pixels, w, h) = Self::extract_raster_selection( &kf.raw_pixels, kf.width, kf.height, raster_sel, ); - self.clipboard_manager.try_set_raster_image(&pixels, w, h); self.clipboard_manager.copy(ClipboardContent::RasterPixels { pixels, width: w, height: h, }); @@ -2294,29 +2293,15 @@ impl EditorApp { self.selection.add_clip_instance(id); } } - ClipboardContent::Shapes { shapes } => { - let active_layer_id = match self.active_layer_id { - Some(id) => id, - None => return, - }; - - // Add shapes to the active vector layer's keyframe - let document = self.action_executor.document_mut(); - let layer = match document.get_layer_mut(&active_layer_id) { - Some(l) => l, - None => return, - }; - - let vector_layer = match layer { - AnyLayer::Vector(vl) => vl, - _ => { - eprintln!("Cannot paste shapes: not a vector layer"); - return; - } - }; - - // TODO: DCEL - paste shapes not yet implemented - let _ = (vector_layer, shapes); + ClipboardContent::VectorGeometry { .. } => { + // TODO (Phase 2): paste DCEL subgraph once vector serialization is defined. + } + ClipboardContent::Layers { .. } => { + // TODO: insert copied layers as siblings at the current selection point. + } + ClipboardContent::AudioNodes { .. } => { + // TODO: add nodes to the target layer's audio graph with new IDs and + // sync to the DAW backend. } ClipboardContent::MidiNotes { .. } => { // MIDI notes are pasted directly in the piano roll pane, not here @@ -2324,7 +2309,8 @@ impl EditorApp { ClipboardContent::RasterPixels { pixels, width, height } => { let Some(layer_id) = self.active_layer_id else { return }; let document = self.action_executor.document(); - let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return }; + let layer = document.get_layer(&layer_id); + let Some(AnyLayer::Raster(rl)) = layer else { return }; let Some(kf) = rl.keyframe_at(self.playback_time) else { return }; // Paste position: top-left of the current raster selection if any,