Observing changes
You will learn how a host sees every change the editor makes to the document, without diffing documents on each frame: what was typed, which comment was added, and whether the change came from the user, from undo, or from another participant.
DocumentEditorController publishes one DocumentChange for every change to the document: each typed character, command, paste, undo or redo step, remote edit, input rollback and document replacement. Moving the selection or the camera publishes nothing.
dart
import 'dart:async';
import 'package:material_ui/material_ui.dart';
import 'package:vyuh_document_editor/vyuh_document_editor.dart';
void main() => runApp(
MaterialApp(
theme: DocumentEditorTheme.light().materialTheme,
home: const ChangeFeed(),
),
);
class ChangeFeed extends StatefulWidget {
const ChangeFeed({super.key});
@override
State<ChangeFeed> createState() => _ChangeFeedState();
}
class _ChangeFeedState extends State<ChangeFeed> {
final controller = DocumentEditorController.richText(
layoutMode: DocumentEditorLayoutMode.pageless,
);
final log = <String>[];
late final StreamSubscription<DocumentChange> changes;
@override
void initState() {
super.initState();
// One DocumentChange per published edit: typing, paste, commands, undo,
// redo, remote edits, IME rollback, replacement. Selection moves: none.
changes = controller.changes.listen((change) {
final line = StringBuffer(
'r${change.contentRevision} ${change.origin.name}'
'${change.isComposing ? ' (composing)' : ''}',
);
// In-block text edits, in the block's text units.
for (final edit in change.textChanges) {
line.write(
' ${edit.blockId}@${edit.offset} '
'+"${edit.inserted}" -${edit.removedLength}',
);
}
// Comments added, updated or removed by this change.
if (!change.comments.isEmpty) {
line.write(' comments +${change.comments.added.length}');
}
// Keep the last 50, newest first.
setState(() => log.insert(0, line.toString()));
if (log.length > 50) log.removeLast();
});
}
@override
void dispose() {
changes.cancel();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: DocumentEditor(controller: controller)),
Expanded(
child: Column(
children: [
TextButton(
onPressed: () => setState(log.clear),
child: const Text('Clear log'),
),
Expanded(
child: ListView(children: [for (final l in log) Text(l)]),
),
],
),
),
],
),
);
}Try it live: type, undo and paste, and watch each change arrive.
Subscribe
| Member | Meaning |
|---|---|
controller.changes | A Stream<DocumentChange>, one event per change |
controller.lastChange | The latest change, or null before the first one |
Events arrive synchronously, right after the controller notifies its listeners, so a listener reads the state its change produced. A listener may edit the document; that edit arrives as the next change.
What a change carries
| Field | Meaning |
|---|---|
origin | Where it came from (see below) |
before, after | The document before and after |
contentRevision | The content revision after the change |
transactions | The transactions applied; empty for undo, redo, rollback and replacement |
textChanges | In-block text edits: blockId (a BlockId), offset, inserted, removedLength |
comments | added, updated and removed comments; isEmpty when none |
isComposing | True while an input method is composing; the text is provisional |
textChanges covers text inside blocks, counted in text units (an inline atom is one unit). For structural changes, and for origins without transactions, compare before with after.
Origins
DocumentChangeOrigin | The change came from |
|---|---|
local | The user, or a command run on this controller |
remote | Another participant, applied as remote |
undo, redo | A history step |
inputRollback | The input method withdrawing provisional text |
replaced | A whole new document (replaceDocument, loading a source) |
Two editors on one document
Two editors on controllers joined through the typed API share one live document. Each applies the other's edits as remote, and each undoes only its own changes. Don't relay changes between two independent editor controllers by hand: the receiving side would record them as its own, and its undo would revert the other person's work.
dart
import 'package:material_ui/material_ui.dart';
import 'package:vyuh_document/vyuh_document.dart';
import 'package:vyuh_document_editor/document_editor.dart';
void main() => runApp(const MaterialApp(home: TwoEditors()));
class TwoEditors extends StatefulWidget {
const TwoEditors({super.key});
@override
State<TwoEditors> createState() => _TwoEditorsState();
}
class _TwoEditorsState extends State<TwoEditors> {
final asha = DocumentController(
DocumentConfiguration.richText,
author: const Author.person('Asha'),
);
// A second participant on the same live document: its own selection and
// undo history, every change attributed.
late final sam = asha.join(author: const Author.person('Sam'));
@override
void dispose() {
sam.close();
asha.close();
super.dispose();
}
Widget participant(DocumentController me) => Column(
children: [
// Edits from either editor appear in both as they happen.
Expanded(child: DocumentEditor(controller: me)),
// Undo reverts only this participant's edits, mapped over the other's.
TextButton(
onPressed: () => me.run(const Undo()),
child: Text('Undo ${me.author.name}'),
),
// Every participant's change arrives here; participantId says whose.
Expanded(
child: StreamBuilder<Change>(
stream: me.changes,
builder: (context, snapshot) {
final change = snapshot.data;
if (change == null) return const Text('No changes yet');
final whose = change.participantId == me.participantId
? 'mine'
: 'from ${change.author.name}';
return Text(
'r${change.revision} ${change.command.name.value} $whose',
);
},
),
),
],
);
@override
Widget build(BuildContext context) => Scaffold(
body: Row(
children: [
Expanded(child: participant(asha)),
Expanded(child: participant(sam)),
],
),
);
}Try it live. See Collaboration for participants, attribution and presence.