Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions src/lib/Session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,80 @@ export type ChangeEvent = {
origin: 'transaction' | 'undo' | 'redo';
};

/**
* Checks whether two ops are consecutive lossless text-insertion splices
* into the same property, i.e. the next insertion starts exactly where the
* previous one ended. Lossy inverses (a 6th element carries prior ranges)
* are never coalesced, so exact undo is preserved.
*/
function can_coalesce_insertion_splices(
prev_op: DocumentOperation | undefined,
prev_inverse: DocumentOperation | undefined,
next_op: DocumentOperation,
next_inverse: DocumentOperation
): boolean {
return (
Array.isArray(prev_op) &&
Array.isArray(next_op) &&
prev_op[0] === 'splice' &&
next_op[0] === 'splice' &&
// Pure insertions only
prev_op[3] === 0 &&
next_op[3] === 0 &&
// Lossless inverses only
prev_inverse?.[0] === 'splice' &&
prev_inverse.length === 5 &&
next_inverse[0] === 'splice' &&
next_inverse.length === 5 &&
// Same text property
prev_op[1][0] === next_op[1][0] &&
prev_op[1][1] === next_op[1][1] &&
// Contiguous: the next insertion starts where the previous one ended
// (a lossless insertion inverse's delete_count is the inserted text's
// character length).
next_op[2] === prev_op[2] + prev_inverse[3]
);
}

/**
* Appends a transaction's ops to a history entry, coalescing the most
* common batching pattern — consecutive text-insertion splices — into a
* single splice op. A typing batch then stores one small op instead of one
* per keystroke.
*/
function append_ops_coalescing(
entry: HistoryEntry,
ops: DocumentOperation[],
inverse_ops: DocumentOperation[]
): void {
const last_index = entry.ops.length - 1;
if (
ops.length === 1 &&
can_coalesce_insertion_splices(
entry.ops[last_index],
entry.inverse_ops[last_index],
ops[0],
inverse_ops[0]
)
) {
const prev_op = entry.ops[last_index];
const prev_inverse = entry.inverse_ops[last_index];
entry.ops[last_index] = ['splice', prev_op[1], prev_op[2], 0, prev_op[4] + ops[0][4]];
entry.inverse_ops[last_index] = [
'splice',
prev_op[1],
prev_op[2],
// Both inverses are lossless insertion splices (checked above),
// so their delete_counts are the two character lengths.
prev_inverse[3] + inverse_ops[0][3],
''
];
} else {
entry.ops.push(...ops);
entry.inverse_ops.push(...inverse_ops);
}
}

export default class Session<S extends DocumentSchema = DocumentSchema> {
#selection: Selection | null = $state.raw(null);

Expand Down Expand Up @@ -353,10 +427,10 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
now - this.last_batch_started < BATCH_WINDOW_MS;

if (should_batch) {
// Append to existing history entry (within the batch window)
// Append to existing history entry (within the batch window),
// coalescing consecutive typing splices into one op.
const last_entry = this.history[this.history_index];
last_entry.ops.push(...transaction.ops);
last_entry.inverse_ops.push(...transaction.inverse_ops);
append_ops_coalescing(last_entry, transaction.ops, transaction.inverse_ops);
last_entry.selection_after = this.selection;
// Trigger update
this.history = [...this.history];
Expand Down
193 changes: 154 additions & 39 deletions src/lib/Transaction.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
inspect as doc_inspect,
create_document_draft,
apply_op_to_draft,
splice_annotated_text,
build_reference_counts,
visit_node_references,
validate_node,
Expand All @@ -40,6 +41,7 @@ import type {
Attachment,
Mark,
Annotation,
Text,
DocumentOperation,
DynamicRecord,
Inspection,
Expand All @@ -59,6 +61,23 @@ function is_range_within_bounds(range: Attachment, length: number): boolean {
);
}

/**
* Compares two range arrays (marks or annotations) for exact equality.
*/
function ranges_equal(a: Array<Attachment>, b: Array<Attachment>): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (
a[i].start_offset !== b[i].start_offset ||
a[i].end_offset !== b[i].end_offset ||
a[i].node_id !== b[i].node_id
) {
return false;
}
}
return true;
}

/**
* Transaction class for managing atomic document operations with undo/redo support.
*
Expand Down Expand Up @@ -312,6 +331,124 @@ export default class Transaction {
return this;
}

/**
* Splices a text property: deletes delete_count characters at start,
* then inserts text at start. Mark and annotation ranges are adjusted
* deterministically as part of applying the op (see splice_annotated_text
* in doc_utils.ts), and mark/annotation nodes whose range collapsed away
* are garbage-collected ref-count-aware, like set() does.
*
* Offsets are in characters (grapheme clusters, as segmented by
* Intl.Segmenter — the same unit as all svedit text offsets, see
* get_char_length in utils.ts).
*
* The recorded op is small — [start, delete_count, text] instead of a
* copy of the whole property value — which keeps long editing sessions
* flat in memory and makes the op stream meaningful (diffs, previews,
* persistence). The inverse is a plain splice when re-splicing the
* deleted text restores the prior ranges exactly; deletions that removed
* or truncated ranges are lossy, so their inverse carries the prior
* ranges verbatim.
*
* @param path - Path to a text property
* @param start - Character offset to splice at
* @param delete_count - Number of characters to delete
* @param text - Text to insert at start ('' for pure deletion)
* @returns This transaction instance for method chaining
* @throws {Error} If the path is not a text property or the range is out of bounds
*
* @example
* ```js
* tr.splice(['paragraph_1', 'content'], 5, 0, 'Hello'); // insert
* tr.splice(['paragraph_1', 'content'], 5, 5, ''); // delete
* tr.splice(['paragraph_1', 'content'], 5, 5, 'Hello'); // replace
* ```
*/
splice(path: DocumentPath, start: number, delete_count: number, text: string): this {
const path_info = this.inspect(path);
if (path_info?.kind !== 'property' || path_info.type !== 'text') {
throw new Error(
`Transaction.splice requires a path that points to a text property, got ${JSON.stringify(path)}`
);
}

const node = this.get(path.slice(0, -1));
const property_key_str = String(path.at(-1));
const normalized_path = [node.id, property_key_str];

const content_length = get_char_length(node[property_key_str].content);
if (
!Number.isInteger(start) ||
!Number.isInteger(delete_count) ||
start < 0 ||
delete_count < 0 ||
start + delete_count > content_length
) {
throw new Error(
`Splice range [${start}, ${start + delete_count}) is out of bounds for content of length ${content_length}`
);
}

const op: DocumentOperation = ['splice', normalized_path, start, delete_count, text];

// Pure insertions (every keystroke) take a fast path: insertion
// adjustment never removes or truncates ranges, so the inverse is a
// plain splice by construction and no value copies or round-trip
// checks are needed.
if (delete_count === 0) {
this.ops.push(op);
this.inverse_ops.push(['splice', normalized_path, start, get_char_length(text), '']);
this._apply_op(op);
this._track_node_id(this.modified_node_ids, node.id);
return this;
}

const previous_value: Text = structuredClone($state.snapshot(node[property_key_str]));
const deleted_text = char_slice(previous_value.content, start, start + delete_count);
const insert_length = get_char_length(text);

const { value: next_value, removed_node_ids } = splice_annotated_text(
previous_value,
start,
delete_count,
text
);

// The inverse can be a plain splice only when re-splicing the deleted
// text back restores the prior ranges exactly (round-trip check).
const { value: round_trip } = splice_annotated_text(
next_value,
start,
insert_length,
deleted_text
);
const lossless =
ranges_equal(round_trip.marks, previous_value.marks) &&
ranges_equal(round_trip.annotations, previous_value.annotations);

const inverse_op: DocumentOperation = lossless
? ['splice', normalized_path, start, insert_length, deleted_text]
: [
'splice',
normalized_path,
start,
insert_length,
deleted_text,
{ marks: previous_value.marks, annotations: previous_value.annotations }
];

this.ops.push(op);
this.inverse_ops.push(inverse_op);
this._apply_op(op);
this._track_node_id(this.modified_node_ids, node.id);

if (removed_node_ids.length > 0) {
this._cascade_delete_unreferenced_nodes(removed_node_ids);
}

return this;
}

// Takes a subgraph and constructs new nodes from it
// NOTE: all ids will be mapped to new unique ids.
// NOTE: Omitted properties will be populated with default values.
Expand Down Expand Up @@ -713,26 +850,9 @@ export default class Transaction {
};
} else if (this.selection.type === 'text') {
const path = this.selection.path;
const text = structuredClone($state.snapshot(this.get(path)));

// Update the text content using character-based operations
const original_text = text.content;
text.content =
char_slice(original_text, 0, start) +
char_slice(original_text, end, get_char_length(original_text));

const marks_result = adjust_ranges_for_deletion(text.marks, start, end);
const annotations_result = adjust_ranges_for_deletion(text.annotations, start, end);
text.marks = marks_result.ranges;
text.annotations = annotations_result.ranges;

this.set(path, text);

// Delete removed mark/annotation nodes only if no other references remain.
this._cascade_delete_unreferenced_nodes([
...marks_result.removed_node_ids,
...annotations_result.removed_node_ids
]);
// Content change, range adjustment and cleanup of removed
// mark/annotation nodes all happen in splice().
this.splice(path, start, end - start, '');

// Update the selection to the new caret position
this.selection = {
Expand Down Expand Up @@ -870,37 +990,32 @@ export default class Transaction {
this.delete_selection();
}

const text_value = structuredClone($state.snapshot(this.get(this.selection.path)));
const range = get_selection_range(this.selection)!;

// Transform the plain text string using character-based operations
const current_text = text_value.content;
text_value.content =
char_slice(current_text, 0, range.start_offset) +
replaced_text +
char_slice(current_text, range.end_offset);

// Calculate the change in length
const delta = get_char_length(replaced_text);

text_value.marks = adjust_ranges_for_insertion(text_value.marks, range.start_offset, delta);
text_value.annotations = adjust_ranges_for_insertion(
text_value.annotations,
range.start_offset,
delta
);
this.set(this.selection.path, text_value); // this will update the current state and create a history entry
// One splice op carries the content change and the deterministic
// mark/annotation range adjustment (see splice()).
this.splice(this.selection.path, range.start_offset, 0, replaced_text);

// Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
const new_selection: Selection = {
type: 'text',
path: this.selection.path,
anchor_offset: range.start_offset + get_char_length(replaced_text),
focus_offset: range.start_offset + get_char_length(replaced_text)
anchor_offset: range.start_offset + delta,
focus_offset: range.start_offset + delta
};
this.selection = new_selection;

// Plain typing has no marks/annotations to restore — skip the value
// copies below on the per-keystroke path.
if (marks.length === 0 && annotations.length === 0) {
return this;
}

const text_property_definition = this.inspect(this.selection.path);
// Restored marks/annotations below are applied on top of the
// post-splice value as a separate set op.
const text_value: Text = structuredClone($state.snapshot(this.get(this.selection.path)));
const next_text = structuredClone(text_value);
let text_changed = false;

Expand Down
Loading