Skip to content

Session Clipboard

Clipboard operations (copy, cut, paste) become a Session-level concern, following the same pattern as export and undo. The Modality trait gains static clipboard methods with default no-ops — copy_selection, cut_selection, delete_selection, paste_rich, paste_text. Every copy/cut produces two payloads: plain text (for external apps) and a JSON rich payload (for lossless round-trip within Tutero). The Dart side owns platform clipboard I/O via SuperClipboard; Rust owns data extraction and application logic.

Session routes Copy/Cut/Paste intents to the modality’s clipboard methods — exactly how Export routes to M::export_bytes(). Context-aware delegation handles nesting: when a Prose element has focus inside a Whiteboard, clipboard delegates to Prose; when in selection mode, it delegates to Whiteboard’s element-level clipboard.

Depends on Runtime Struct for the Effect return model. Interacts with Embedded Prose Reducer — embedded Prose clipboard delegates through the parent Whiteboard. Interacts with Session Undo/Redo — cut is a single undo step (copy + delete in one reduce call). Depends on Selection Trait — clipboard operations act on the current selection.

Today, clipboard is fragmented:

  1. Prose has a complete clipboard systemProseIntent::Copy/Cut/Paste/PasteRich/PasteMarkdown, ProseEffect::CopyToClipboard/RequestPaste, Dart-side SuperClipboard integration with custom format com.tutero.prose-styled. Works well for standalone Prose, but is self-contained and not reusable.

  2. Whiteboard has no clipboard support — no Copy/Cut/Paste intents, no element serialization for clipboard, no way to duplicate elements via clipboard. Users expect Cmd+C/V to copy and paste selected elements.

  3. LessonPlan has no clipboard support — no way to copy/paste entire slides. Users who want to duplicate a slide or move content between lesson plans have no clipboard path.

  4. No standardized pattern — each modality would need to independently design its own clipboard intents, effects, and Dart integration. Prose’s pattern (effect sink for clipboard events) works but isn’t shared.

  5. Nesting is unaddressed — when editing text inside a Whiteboard element, should Cmd+C copy the text (Prose behavior) or the element (Whiteboard behavior)? No mechanism exists to route clipboard operations based on focus context.

  6. Two-payload requirement — every clipboard operation needs both a plain text representation (for pasting into external apps) and a structured rich payload (for lossless round-trip within Tutero). This dual-payload pattern needs to be standardized rather than reinvented per modality.

We want one clipboard system: Modality provides per-modality behavior via default-no-op methods, Session routes intents, and a shared Dart integration handles platform I/O.

Clipboard methods live directly on the Modality trait with default no-ops — the same pattern as export_bytes. No separate trait, no additional bounds on ReduceIntent. All methods are static over &Self::State / &mut Self::State, consistent with Reducer::reduce(cmd, state).

/// Links a data type to its clipboard format identifier.
/// Ensures format string and serialized data are always consistent.
pub trait ClipboardFormat: Serialize + DeserializeOwned {
/// Reverse-domain format ID (e.g., "com.tutero.whiteboard-elements").
const FORMAT: &'static str;
}
/// Serialized clipboard payload — carried through ClipboardService to Dart.
/// Construct via `ClipboardPayload::new()` to enforce format-type linkage.
pub struct ClipboardPayload {
/// Plain text for external apps (pasteboard plain text format).
pub text: String,
/// Structured data for internal round-trip (JSON-serialized).
pub rich: String,
/// Format identifier for SuperClipboard custom format.
pub format: String,
}
impl ClipboardPayload {
/// Construct a payload from typed data. Format is derived from the type —
/// impossible to mismatch format string and data.
pub fn new<F: ClipboardFormat>(text: String, data: &F) -> Self {
Self {
text,
rich: serde_json::to_string(data).unwrap(),
format: F::FORMAT.to_string(),
}
}
/// Try to parse the rich payload as a specific type.
/// Only succeeds if the format matches AND deserialization succeeds.
pub fn parse<F: ClipboardFormat>(&self) -> Option<F> {
if self.format == F::FORMAT {
serde_json::from_str(&self.rich).ok()
} else {
None
}
}
}

Each clipboard data type implements ClipboardFormat to bind its format string:

impl ClipboardFormat for Vec<ElementData> {
const FORMAT: &'static str = "com.tutero.whiteboard-elements";
}
impl ClipboardFormat for Vec<StyledSegment> {
const FORMAT: &'static str = "com.tutero.prose-styled";
}
impl ClipboardFormat for Vec<SlideData> {
const FORMAT: &'static str = "com.tutero.lesson-slides";
}

Modality clipboard methods on the trait:

trait Modality: Reducer + Component {
// ... existing associated types and methods ...
/// Produce clipboard payloads from the current selection.
/// Returns None if nothing is selected or copiable.
fn copy_selection(state: &Self::State) -> Option<ClipboardPayload> {
None
}
/// Copy the current selection and delete it.
/// Default: copy_selection() then delete_selection().
fn cut_selection(state: &mut Self::State) -> Option<ClipboardPayload> {
let payload = Self::copy_selection(state);
if payload.is_some() {
Self::delete_selection(state);
}
payload
}
/// Delete the current selection without copying.
/// Also useful independently for the Delete key.
fn delete_selection(state: &mut Self::State) {}
/// Attempt to paste from a rich (internal) format.
/// The payload contains both format and data — use `payload.parse::<T>()`
/// to check format and deserialize in one step.
fn paste_rich(state: &mut Self::State, payload: &ClipboardPayload) -> bool {
false
}
/// Paste plain text (external fallback when rich format doesn't match).
fn paste_text(state: &mut Self::State, text: &str) {}
}

Modalities that don’t need clipboard (e.g., Assessment) inherit the no-ops for free. paste_rich returns bool so Session knows whether to fall through to paste_text. It receives the full ClipboardPayload so it can call payload.parse::<T>() for each format it accepts.

Each data type implements ClipboardFormat to bind its format string. Reverse-domain convention matches SuperClipboard’s CustomValueFormat:

Data typeFormatContents
Vec<ElementData>com.tutero.whiteboard-elementsElement data (position, properties, type)
Vec<StyledSegment>com.tutero.prose-styledStyled text (marks, color, links)
Vec<WorksheetItemData>com.tutero.worksheet-itemsWorksheet item data
Vec<SlideData>com.tutero.lesson-slidesSlide data (child synced state)

A modality’s copy_selection chooses the type (and therefore format) based on context — ClipboardPayload::new(text, &segments) produces prose-styled, ClipboardPayload::new(text, &elements) produces whiteboard-elements. On paste, payload.parse::<Vec<ElementData>>() checks format + deserializes in one step.

SessionIntent gains clipboard variants alongside Undo/Redo:

pub enum SessionIntent<I> {
Modality(I),
Ai(AiIntent),
Export(OutputFormat),
Undo,
Redo,
// New:
Copy,
Cut,
Paste(ClipboardPayload),
}

Copy and Cut are parameterless — the modality reads its own state to determine what’s selected. Paste carries a ClipboardPayload constructed by Dart from SuperClipboard contents.

Follows the AgentService pattern — a write-only channel that Session’s effects can push clipboard events through:

pub struct ClipboardService {
tx: mpsc::Sender<ClipboardEvent>,
}
pub enum ClipboardEvent {
/// Write these payloads to the platform clipboard.
Write(ClipboardPayload),
}
impl ClipboardService {
pub fn write(&self, payload: ClipboardPayload) {
let _ = self.tx.send(ClipboardEvent::Write(payload));
}
}

Added to SessionServices:

pub struct SessionServices {
pub backend: Arc<dyn StoreBackend>,
pub clipboard: ClipboardService,
}

The API layer creates the channel, passes the sender to SessionServices, and exposes the receiver as a FRB StreamSink to Dart.

Clipboard arms slot into the existing SessionIntent::reduce match — no additional trait bounds needed:

impl<M: Modality> ReduceIntent<M> for SessionIntent<M::Intent> {
fn reduce(self, state: &mut SessionState<M>) -> Effect<SessionServices, Self, Self::Feedback> {
match self {
// ... existing Modality/Ai/Export/Undo/Redo arms ...
SessionIntent::Copy => {
if let Some(payload) = M::copy_selection(state.modality_state()) {
Effect::task(move |svc| svc.clipboard.write(payload))
} else {
Effect::none()
}
}
SessionIntent::Cut => {
if let Some(payload) = M::cut_selection(state.modality_state_mut()) {
Effect::task(move |svc| svc.clipboard.write(payload))
} else {
Effect::none()
}
}
SessionIntent::Paste(payload) => {
if !M::paste_rich(state.modality_state_mut(), &payload) {
M::paste_text(state.modality_state_mut(), &payload.text);
}
Effect::none()
}
}
}
}

Copy/Cut return Effect::task to push the payload through ClipboardService. Paste passes the full ClipboardPayload to paste_rich — the modality calls payload.parse::<T>() for each format it accepts. If none match, Session falls through to paste_text. ParentSessionIntent::Base(SessionIntent::Copy/Cut/Paste) routes through the same arms — same duplication pattern already used for Ai/Export/Undo.

Whiteboard’s clipboard methods check the current editing context to delegate between element-level and prose-level operations:

impl Modality for Whiteboard {
// ... existing ...
fn copy_selection(state: &Self::State) -> Option<ClipboardPayload> {
if let Some(prose_id) = &state.ephemeral.editing_text_element {
// Prose has focus → prose-level copy
let (text, segments) = copy_prose_selection(state, prose_id)?;
Some(ClipboardPayload::new(text, &segments))
// format auto-derived: "com.tutero.prose-styled"
} else {
// Selection mode → element-level copy
let elements = copy_selected_elements(state)?;
let text = elements_as_text(&elements);
Some(ClipboardPayload::new(text, &elements))
// format auto-derived: "com.tutero.whiteboard-elements"
}
}
fn paste_rich(state: &mut Self::State, payload: &ClipboardPayload) -> bool {
// Try prose-styled (only when editing text)
if let Some(segments) = payload.parse::<Vec<StyledSegment>>() {
if let Some(prose_id) = state.ephemeral.editing_text_element.clone() {
paste_prose_segments(state, &prose_id, &segments);
return true;
}
// Format matched but not in text mode — fall through
}
// Try whiteboard elements
if let Some(elements) = payload.parse::<Vec<ElementData>>() {
paste_elements(state, elements);
return true;
}
false
}
fn paste_text(state: &mut Self::State, text: &str) {
if let Some(prose_id) = state.ephemeral.editing_text_element.clone() {
paste_prose_text(state, &prose_id, text);
} else {
paste_as_new_text_element(state, text);
}
}
fn delete_selection(state: &mut Self::State) {
if let Some(prose_id) = state.ephemeral.editing_text_element.clone() {
delete_prose_selection(state, &prose_id);
} else {
delete_selected_elements(state);
}
}
}

Decision flow for Whiteboard:

  • Text editing mode (prose has focus) → Prose-level operations, com.tutero.prose-styled format
  • Selection mode → element-level operations, com.tutero.whiteboard-elements format
  • Plain text paste with no prose focus → create new text element

ClipboardPayload::new() derives the format from the data type — impossible to mismatch. payload.parse::<T>() checks both format string and deserialization in one call. Pasting Prose-styled content from a standalone Prose session into a Whiteboard in text editing mode works naturally — parse::<Vec<StyledSegment>>() matches the format and deserializes. Pasting it in selection mode returns false → falls through to paste_text.

LessonPlan delegates to the focused child when editing a slide, otherwise handles slide-level operations:

impl Modality for LessonPlan {
// ... existing ...
fn copy_selection(state: &Self::State) -> Option<ClipboardPayload> {
if let Some(child_id) = focused_editing_child(state) {
// Delegate to child modality's clipboard
let child = state.synced.children.get(&child_id)?;
if let Some(arc) = child.as_hot() {
let locked = arc.lock();
Whiteboard::copy_selection(locked.state().modality.state())
} else {
None
}
} else {
// Copy selected slide(s)
let slides = copy_selected_slides(state)?;
Some(ClipboardPayload::new(slides_as_text(&slides), &slides))
// format auto-derived: "com.tutero.lesson-slides"
}
}
fn paste_rich(state: &mut Self::State, payload: &ClipboardPayload) -> bool {
// Try lesson slides
if let Some(slides) = payload.parse::<Vec<SlideData>>() {
paste_slides(state, slides);
return true;
}
// Delegate to focused child for other formats
if let Some(child_id) = focused_editing_child(state) {
if let Some(child) = state.synced.children.get(&child_id) {
if let Some(arc) = child.as_hot() {
let mut locked = arc.lock();
return Whiteboard::paste_rich(
locked.state_mut().modality.state_mut(), payload
);
}
}
}
false
}
fn paste_text(state: &mut Self::State, text: &str) {
if let Some(child_id) = focused_editing_child(state) {
if let Some(child) = state.synced.children.get(&child_id) {
if let Some(arc) = child.as_hot() {
let mut locked = arc.lock();
Whiteboard::paste_text(locked.state_mut().modality.state_mut(), text);
}
}
}
// No action at parent level for plain text paste
}
fn delete_selection(state: &mut Self::State) {
if let Some(child_id) = focused_editing_child(state) {
if let Some(child) = state.synced.children.get(&child_id) {
if let Some(arc) = child.as_hot() {
let mut locked = arc.lock();
Whiteboard::delete_selection(locked.state_mut().modality.state_mut());
}
}
} else {
delete_selected_slides(state);
}
}
}

Slide serialization includes the whiteboard synced state (element data, layout, properties). On paste, a new slide is created from the deserialized state with regenerated element IDs.

Keyboard shortcuts (Cmd+C, Cmd+X, Cmd+V) are handled at the Dart controller level. The controller dispatches session intents and handles platform clipboard I/O.

Copy/Cut flow (Rust → Dart):

Dart catches Cmd+C → dispatch SessionIntent::Copy
→ Session reduce → M::copy_selection(state)
→ Effect::task → ClipboardService.write(payload)
→ Dart receives ClipboardEvent::Write
→ SuperClipboard.write([plain_text, custom_format])

Paste flow (Dart → Rust):

Dart catches Cmd+V → SuperClipboard.read()
→ Check for known custom formats
→ dispatch SessionIntent::Paste { text, rich, format }
→ Session reduce → M::paste_rich() or M::paste_text()

Dart controller implementation:

class SessionController<M> extends ChangeNotifier {
late final StreamSubscription<ClipboardEvent> _clipboardSub;
void _onClipboardEvent(ClipboardEvent event) {
switch (event) {
case ClipboardEvent_Write(:final payload):
_writeToClipboard(payload);
}
}
Future<void> _writeToClipboard(ClipboardPayload payload) async {
final item = DataWriterItem();
item.add(Formats.plainText(payload.text));
item.add(CustomValueFormat<Uint8List>(
applicationId: payload.format,
).call(utf8.encode(payload.rich)));
await SystemClipboard.instance?.write([item]);
}
Future<void> handlePaste() async {
final reader = await SystemClipboard.instance?.read();
if (reader == null) return;
// Try known custom formats in priority order
for (final format in _knownFormats) {
final data = await reader.readValue(CustomValueFormat<Uint8List>(
applicationId: format,
));
if (data != null) {
dispatch(SessionIntent.paste(ClipboardPayload(
text: '',
rich: utf8.decode(data),
format: format,
)));
return;
}
}
// Fallback: plain text
final text = await reader.readValue(Formats.plainText);
if (text != null) {
dispatch(SessionIntent.paste(ClipboardPayload(
text: text, rich: '', format: '',
)));
}
}
// Known custom formats for this modality type
static const _knownFormats = [
'com.tutero.whiteboard-elements',
'com.tutero.prose-styled',
'com.tutero.worksheet-items',
'com.tutero.lesson-slides',
];
}

The format priority list is checked in order. Any Tutero app can paste content from any other. The modality’s paste_rich returns false for unrecognized formats, falling through to paste_text.

Prose’s existing clipboard system (ProseEffect::CopyToClipboard, ProseEffect::RequestPaste, ProseIntent::Copy/Cut/Paste/PasteRich/PasteMarkdown) continues to work for standalone Prose sessions. When Prose is embedded in a Whiteboard (the Embedded Prose RFC), the clipboard path goes through Whiteboard’s context-aware delegation instead — Whiteboard calls Prose’s existing clipboard functions (clipboard::copy_styled_segments, clipboard::paste_styled_segments) directly.

The standalone ProseSession wrapper retains its own clipboard handling. Embedded Prose instances have their clipboard operations routed through the parent Whiteboard.

Interaction with undo (the Session Undo/Redo RFC)

Section titled “Interaction with undo (the Session Undo/Redo RFC)”

Cut is a single reduce() call that copies and deletes — it produces one LoroDoc commit and therefore one undo step. Paste is also a single reduce call. No special undo grouping is needed for clipboard operations.

  1. ClipboardPayload + Modality methods — add ClipboardPayload struct to modality_core. Add copy_selection, cut_selection, delete_selection, paste_rich, paste_text to Modality trait with default no-ops. Verify: cargo check --workspace

  2. ClipboardService + SessionIntent variants — add ClipboardService to modality_session, add to SessionServices. Add Copy/Cut/Paste variants to SessionIntent. Wire reduce arms for both SessionIntent and ParentSessionIntent. Verify: cargo check --workspace

  3. Whiteboard clipboard — implement clipboard methods for Whiteboard. Element-level copy/paste: serialize selected elements as JSON, deserialize on paste with new IDs and offset positions. Context-aware delegation to Prose when a text element has focus. Verify: cargo test --workspace

  4. Worksheet clipboard — implement clipboard methods for Worksheet. Item-level copy/paste. Verify: cargo test --workspace

  5. LessonPlan clipboard — implement clipboard methods for LessonPlan. Slide-level copy/paste with child delegation when a slide has focus. Serialize slide synced state for the rich payload. Verify: cargo test --workspace

  6. FRB + Dart clipboard event stream — expose ClipboardEvent stream via FRB. Add ClipboardPayload as non_opaque. Wire open_*_session functions to accept clipboard event sink. Verify: flutter_rust_bridge_codegen generate && flutter analyze

  7. Dart controller integration — update session controllers to listen to clipboard event stream, write to SuperClipboard on ClipboardEvent::Write. Handle Cmd+C/X/V keyboard shortcuts: dispatch Copy/Cut intents, read clipboard and dispatch Paste on Cmd+V. Move super_clipboard dependency from prose to modality package. Verify: flutter analyze

  8. Tests — unit tests for each modality’s clipboard methods (copy produces correct payload, cut deletes, paste_rich round-trips, paste_text fallback). Integration test for context-aware delegation in Whiteboard. Verify: cargo test --workspace

Separate Clipboard trait on Reducer — a standalone Clipboard trait with a generic Rich associated type, bounded on Reducer. Rejected because no other Session-level concern uses a separate trait — export is a method on Modality, AI is a free function, undo is on SessionState. A separate trait adds a new bound to ReduceIntent and requires no-op impls with type Rich = () for non-clipboard modalities. Methods on Modality are simpler and consistent.

Session handles all clipboard logic — Session reads modality state and builds clipboard payloads directly. Rejected because it couples Session to modality-specific details (what “selection” means, how elements serialize). The Modality methods keep domain logic in the modality.

Effect-based clipboard per modality (current Prose pattern) — each modality defines its own clipboard effects and Dart integration. Rejected because it duplicates the SuperClipboard integration, format handling, and intent/effect plumbing in every modality. Session-level routing eliminates this duplication.

Clipboard at Dart level only — Dart reads the snapshot, builds clipboard content, and applies paste by dispatching modality intents. Rejected because Dart would need to understand element serialization formats and selection semantics for every modality. Keeping this logic in Rust is more maintainable and testable.

Always dual-payload (Whiteboard elements + Prose text simultaneously) — every copy produces both element-level and text-level payloads. Rejected in favor of context-aware delegation, which is simpler and matches user intent: if you’re editing text, you want to copy text; if you’re selecting elements, you want to copy elements.

Undo group for cut — wrap cut in Session::batch() for explicit undo grouping. Unnecessary because cut is a single reduce() call → single commit → single undo step automatically.

Platform clipboard I/O is Dart’s responsibility. Rust produces/consumes the data; Dart reads/writes the platform clipboard via SuperClipboard. This avoids FFI complexity for platform clipboard APIs and leverages SuperClipboard’s cross-platform support (macOS, iOS, Android, Windows, Linux, Web).

Clipboard methods on Modality, not a separate trait

Section titled “Clipboard methods on Modality, not a separate trait”

Follows the export_bytes pattern: static method on Modality with a default no-op. No additional trait bounds, no generic associated types, no separate impls to maintain. Each modality handles its own serialization internally — consistent with how each modality owns its own export logic.

Format-type linkage via ClipboardFormat trait

Section titled “Format-type linkage via ClipboardFormat trait”

The ClipboardFormat trait binds each data type to its format string at the type level. ClipboardPayload::new::<F>() derives the format from the type — impossible to write "com.tutero.whiteboard-elements" but serialize Vec<StyledSegment>. payload.parse::<F>() checks format match AND deserializes in one call, eliminating a class of bugs where format checking and deserialization could get out of sync.

Reverse-domain strings (com.tutero.*) match SuperClipboard’s CustomValueFormat convention. A single modality can accept multiple formats (Whiteboard accepts both prose-styled and whiteboard-elements by calling parse for each).

Plain text paste into Whiteboard creates a new text element

Section titled “Plain text paste into Whiteboard creates a new text element”

When pasting plain text into a Whiteboard that isn’t in text-editing mode, a new text element is created with the pasted content. This matches user expectations from Figma and other canvas tools.

All rich payloads are JSON-serialized via serde_json. Each modality serializes in copy_selection and deserializes in paste_rich. JSON is human-readable for debugging, and SuperClipboard stores custom formats as Uint8List (UTF-8 encoded JSON bytes).

Paste format priority: rich first, then plain text

Section titled “Paste format priority: rich first, then plain text”

Dart reads all known custom formats before falling back to plain text. The first matching format wins. The known-formats list is static (all Tutero formats) — any session can receive paste from any other.

  • Cross-modality paste semantics — what happens when you copy whiteboard elements and paste into a worksheet, or vice versa? The modality’s paste_rich returns false for unrecognized formats, falling back to plain text. Is this sufficient, or should we support cross-modality rich paste?

  • Multiple element paste positioning — when pasting multiple whiteboard elements, where do they go? Offset from original positions? Centered on viewport? Stacked at cursor? Needs UX decision.

  • Slide paste ordering — when pasting slides in LessonPlan, do they insert after the selected slide? At the end? Needs UX decision.

  • Image/file clipboard — SuperClipboard supports image formats (Formats.png, Formats.jpeg). Pasting an image from an external source could create a file element in Whiteboard. Deferred to a future RFC.

  • Clipboard across windows/tabs — the platform clipboard naturally handles this (write in one window, read in another). No special handling needed, but cross-session paste of com.tutero.* formats should be tested.

  • LessonPlan child session locking — LessonPlan’s clipboard methods lock child sessions to delegate. Implementation needs to verify lock ordering is safe and won’t deadlock with the parent session lock.