Editor/Prism/Model/StickyNote.cs

A model type used by the Editor to represent a free-floating sticky note on a canvas. Stores id, position, size, color and text, computes a clamped Rect, provides summary text, hit testing, cloning, and string representation.

using Editor.Prism.Core;

namespace Editor.Prism.Model;

/// <summary>
/// A free-floating annotation on the canvas. Unlike a <see cref="GraphGroup"/> it owns nothing and
/// encloses nothing — it is documentation the author leaves for whoever opens the graph next.
/// </summary>
public sealed class StickyNote
{
	/// <summary>Build an empty note with a fresh id.</summary>
	public StickyNote()
	{
		Id = Ids.NewShortId();
	}

	/// <summary>Build a note at a position with a fresh id.</summary>
	public StickyNote( string text, Vector2 position ) : this()
	{
		Text = text;
		Position = position;
	}

	/// <summary>Stable id, minted once and never rewritten.</summary>
	public string Id { get; set; }

	/// <summary>Top-left corner in scene space.</summary>
	public Vector2 Position { get; set; }

	/// <summary>Width and height in scene space.</summary>
	public Vector2 Size { get; set; } = DefaultSize;

	/// <summary>Named colour from <see cref="GraphGroup.Palette"/>.</summary>
	public string Color { get; set; } = "Yellow";

	/// <summary>The note body. Plain text; newlines are preserved.</summary>
	public string Text { get; set; } = string.Empty;

	/// <summary>The note's scene rectangle, with the minimum size enforced.</summary>
	public Rect Rect
	{
		get => new( Position, new Vector2( Math.Max( Size.x, MinSize.x ), Math.Max( Size.y, MinSize.y ) ) );
		set
		{
			Position = value.Position;
			Size = value.Size;
		}
	}

	/// <summary>The first line of the note, for compact listings and tooltips.</summary>
	public string Summary
	{
		get
		{
			if ( string.IsNullOrEmpty( Text ) ) return string.Empty;

			var end = Text.IndexOfAny( s_lineBreaks );
			var line = end < 0 ? Text : Text[..end];

			return line.Length <= 64 ? line : line[..61] + "...";
		}
	}

	/// <summary>True when a scene-space point lies inside the note.</summary>
	public bool Contains( Vector2 point )
	{
		var rect = Rect;

		return point.x >= rect.Left && point.x <= rect.Left + rect.Width &&
			point.y >= rect.Top && point.y <= rect.Top + rect.Height;
	}

	/// <summary>Deep copy, keeping the same id.</summary>
	public StickyNote Clone() => new()
	{
		Id = Id,
		Position = Position,
		Size = Size,
		Color = Color,
		Text = Text
	};

	/// <summary>Deep copy with a freshly minted id.</summary>
	public StickyNote CloneWithNewId()
	{
		var copy = Clone();
		copy.Id = Ids.NewShortId();
		return copy;
	}

	/// <inheritdoc/>
	public override string ToString() => $"Note '{Summary}'";

	/// <summary>The size a freshly created note gets.</summary>
	public static readonly Vector2 DefaultSize = new( 260f, 96f );

	/// <summary>The smallest a note may be, so it stays grabbable.</summary>
	public static readonly Vector2 MinSize = new( 96f, 48f );

	static readonly char[] s_lineBreaks = { '\r', '\n' };
}