ClipboardCodec and PasteResult for the Prism editor. PasteResult is a small record describing the result of a paste (success, nodes, center, error). ClipboardCodec encodes and decodes clipboard payloads with a fixed prefix, Base64 and GZip, bounds decompressed size, and provides Paste and Duplicate operations that serialize/deserialize node fragments and position them in the graph.
using Editor.Prism.Core;
using Editor.Prism.Model;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace Editor.Prism.Serialization;
/// <summary>What a paste produced.</summary>
public sealed record PasteResult
{
/// <summary>True when the payload was Prism's and at least part of it was pasted.</summary>
public bool Ok { get; init; }
/// <summary>The nodes that were added, with freshly minted ids.</summary>
public IReadOnlyList<PrismNode> Nodes { get; init; } = Array.Empty<PrismNode>();
/// <summary>Why the paste did nothing, when it did nothing.</summary>
public string Error { get; init; }
/// <summary>The centre of the pasted selection in scene space, for framing the view.</summary>
public Vector2 Center { get; init; }
/// <summary>The failure result.</summary>
public static PasteResult Failed( string error ) => new() { Ok = false, Error = error };
/// <inheritdoc/>
public override string ToString() => Ok ? $"pasted {Nodes.Count} nodes" : $"paste failed: {Error}";
}
/// <summary>
/// The clipboard format: <c>"prism.graph:"</c> followed by Base64 of a GZipped document fragment.
/// <para>
/// The identifier prefix is not decoration. The node-graph framework's paste path checks only that
/// the clipboard text starts with the editor's own prefix, so an editor that reuses another's prefix
/// happily hands a foreign graph to its own deserializer and throws. Prism's prefix is unique, and
/// <see cref="TryDecode"/> refuses anything that is not ours before a single byte is parsed.
/// </para>
/// </summary>
public static class ClipboardCodec
{
/// <summary>The literal prefix every Prism clipboard payload starts with.</summary>
public const string Prefix = PrismConstants.ClipboardIdent + ":";
/// <summary>
/// The largest fragment a paste will decompress, in bytes.
/// <para>
/// The clipboard is the one input to the editor that comes from outside it entirely, and a few
/// kilobytes of GZip expands to as many gigabytes as the author of it likes. A five-thousand-node
/// document is under two megabytes, so this is generous by any real measure and still bounded.
/// </para>
/// </summary>
public const int MaxDecodedBytes = 64 * 1024 * 1024;
/// <summary>True when the text looks like a Prism payload. Cheap enough to call from a menu-enable check.</summary>
public static bool IsPrismPayload( string text ) =>
!string.IsNullOrEmpty( text ) && text.StartsWith( Prefix, StringComparison.Ordinal );
/// <summary>Encode a selection as a clipboard payload.</summary>
public static string Encode( PrismGraph graph, IEnumerable<PrismNode> nodes ) =>
EncodeJson( PrismSerializer.WriteNodes( graph, nodes ) );
/// <summary>Wrap an already-serialized fragment as a clipboard payload.</summary>
public static string EncodeJson( string json )
{
if ( string.IsNullOrEmpty( json ) ) return Prefix;
return PrismLog.Guard( "Encode clipboard payload", () =>
{
using var buffer = new MemoryStream();
using ( var zip = new GZipStream( buffer, CompressionMode.Compress, true ) )
{
var bytes = Encoding.UTF8.GetBytes( json );
zip.Write( bytes, 0, bytes.Length );
}
return Prefix + System.Convert.ToBase64String( buffer.ToArray() );
}, Prefix );
}
/// <summary>
/// Decode a clipboard payload. Returns false for anything that is not ours, for anything that is
/// not valid Base64, and for anything that is not valid GZip — never throws on hostile input.
/// </summary>
public static bool TryDecode( string payload, out string json )
{
json = null;
if ( !IsPrismPayload( payload ) ) return false;
var encoded = payload[Prefix.Length..].Trim();
if ( encoded.Length == 0 ) return false;
byte[] compressed;
try
{
compressed = System.Convert.FromBase64String( encoded );
}
catch ( FormatException )
{
return false;
}
try
{
using var input = new MemoryStream( compressed );
using var zip = new GZipStream( input, CompressionMode.Decompress );
using var output = new MemoryStream();
var chunk = new byte[64 * 1024];
while ( true )
{
var read = zip.Read( chunk, 0, chunk.Length );
if ( read <= 0 ) break;
if ( output.Length + read > MaxDecodedBytes )
{
PrismLog.Warn( $"A clipboard payload expanded past {MaxDecodedBytes / ( 1024 * 1024 )} MB " +
"and was refused." );
return false;
}
output.Write( chunk, 0, read );
}
json = Encoding.UTF8.GetString( output.ToArray() );
}
catch ( Exception e )
{
PrismLog.Trace( $"Clipboard payload could not be decompressed: {e.Message}" );
return false;
}
return !string.IsNullOrWhiteSpace( json );
}
/// <summary>
/// Paste a payload into a document, re-minting every node id and rewiring the fragment's internal
/// edges to the new ids, then translating the result so its centre lands on
/// <paramref name="origin"/>.
/// <para>
/// Ids are re-minted rather than preserved because a paste always creates <em>new</em> nodes, even
/// in the document they were copied from. Undo restore is the opposite case and uses
/// <see cref="PrismSerializer.Restore"/>, which keeps ids exactly.
/// </para>
/// </summary>
public static PasteResult Paste( PrismGraph graph, string payload, Vector2 origin,
DiagnosticSink sink = null )
{
if ( graph is null ) return PasteResult.Failed( "No document" );
if ( !TryDecode( payload, out var json ) )
{
return PasteResult.Failed( IsPrismPayload( payload )
? "The clipboard payload is damaged"
: "The clipboard does not contain Prism nodes" );
}
var added = PrismSerializer.ReadNodes( graph, json, true, sink );
if ( added.Count == 0 ) return PasteResult.Failed( "The clipboard payload contained no nodes" );
var center = Recenter( added, origin );
return new PasteResult { Ok = true, Nodes = added, Center = center };
}
/// <summary>
/// Duplicate nodes inside their own document: the same path as paste, but without a round trip
/// through the system clipboard, and offset rather than re-centred.
/// </summary>
public static PasteResult Duplicate( PrismGraph graph, IEnumerable<PrismNode> nodes, Vector2 offset,
DiagnosticSink sink = null )
{
if ( graph is null ) return PasteResult.Failed( "No document" );
var selection = nodes?.Where( x => x is not null ).ToArray() ?? Array.Empty<PrismNode>();
if ( selection.Length == 0 ) return PasteResult.Failed( "Nothing to duplicate" );
var json = PrismSerializer.WriteNodes( graph, selection );
var added = PrismSerializer.ReadNodes( graph, json, true, sink );
if ( added.Count == 0 ) return PasteResult.Failed( "The selection could not be duplicated" );
foreach ( var node in added )
{
node.Position += offset;
}
return new PasteResult { Ok = true, Nodes = added, Center = Average( added ) };
}
static Vector2 Recenter( IReadOnlyList<PrismNode> nodes, Vector2 origin )
{
var average = Average( nodes );
var delta = origin - average;
foreach ( var node in nodes )
{
node.Position += delta;
}
return origin;
}
static Vector2 Average( IReadOnlyList<PrismNode> nodes )
{
if ( nodes is null || nodes.Count == 0 ) return Vector2.Zero;
var x = 0f;
var y = 0f;
foreach ( var node in nodes )
{
x += node.Position.x;
y += node.Position.y;
}
return new Vector2( x / nodes.Count, y / nodes.Count );
}
}