Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- name: Run eraser cursor regression test
run: npm run test:native:eraser-smoke

- name: Run selection history regression test
run: npm run test:native:selection-smoke

- name: Build package artifacts
run: npm run build

Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

All notable changes to `@mathnotes/mobile-ink` will be documented here.

## [Unreleased]
## [0.3.5] - 2026-10-02

- Fixed a crash when deleting a selection after Undo, Redo or Clear. The selection kept stroke indices from before the history change, and deleting it underflowed a `size_t` and aborted the app with an uncaught `std::length_error`.
- Fixed Delete or Move after Undo acting on strokes the user never selected. Undo, Redo, Clear and loading now drop index-based selection, object-eraser and transform state, and cancel an in-flight selection transform before reverting.
- iOS hides the selection toolbar after Undo, Redo and Clear, so it no longer offers actions on strokes that are gone.
- Added unit tests for `normalizePagePayloadForNativeLoad` covering blank, malformed, and valid native-load payloads (#12).
- Added `npm run test:native:selection-smoke` regression coverage, run in CI.


## [0.3.4] - 2026-08-15
Expand Down
4 changes: 2 additions & 2 deletions cpp/DrawingSelection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ void DrawingSelection::deleteSelection(
size_t newIndex = 0;

std::vector<Stroke> remainingStrokes;
remainingStrokes.reserve(strokes.size() - selectedIndices.size());
remainingStrokes.reserve(strokes.size() - delta.removedStrokes.size());

for (size_t i = 0; i < strokes.size(); i++) {
if (selectedIndices.count(i) == 0) {
Expand All @@ -154,7 +154,7 @@ void DrawingSelection::deleteSelection(
strokes = remainingStrokes;
selectedIndices.clear();

if (commit) commit(std::move(delta));
if (commit && !delta.removedStrokes.empty()) commit(std::move(delta));
}

void DrawingSelection::copySelection(
Expand Down
17 changes: 8 additions & 9 deletions cpp/SkiaDrawingEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -684,13 +684,15 @@ void SkiaDrawingEngine::clear() {
// the one operation that genuinely needs an O(N) snapshot to support
// undo, but it happens once per clear (not per stroke), so the cost
// is bounded.
cancelSelectionTransform();
StrokeDelta delta;
delta.kind = StrokeDelta::Kind::Clear;
delta.clearedStrokes = strokes_;
delta.clearedEraserCircles = eraserCircles_;

strokes_.clear();
eraserCircles_.clear();
resetIndexedSelectionState();
currentPoints_.clear();
currentPath_.reset();
clearActiveShapePreview();
Expand All @@ -714,10 +716,12 @@ void SkiaDrawingEngine::undo() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);

if (undoStack_.empty()) return;
cancelSelectionTransform();
StrokeDelta delta = std::move(undoStack_.back());
undoStack_.pop_back();
revertDelta(delta);
redoStack_.push_back(std::move(delta));
resetIndexedSelectionState();

cachedEraserCircleCount_ = 0;
bakedCircleCount_ = 0;
Expand All @@ -731,10 +735,12 @@ void SkiaDrawingEngine::redo() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);

if (redoStack_.empty()) return;
cancelSelectionTransform();
StrokeDelta delta = std::move(redoStack_.back());
redoStack_.pop_back();
applyDelta(delta);
undoStack_.push_back(std::move(delta));
resetIndexedSelectionState();

cachedEraserCircleCount_ = 0;
bakedCircleCount_ = 0;
Expand Down Expand Up @@ -855,18 +861,11 @@ bool SkiaDrawingEngine::deserializeDrawing(const std::vector<uint8_t>& data) {
return false;
}

cancelSelectionTransform();
strokes_ = std::move(loadedStrokes);
eraserCircles_.clear(); // Clear eraser circles when loading
bakedCircleCount_ = 0; // No circles to bake
selectedIndices_.clear();
isDraggingSelection_ = false;
hasDragCache_ = false;
selectionOffsetX_ = 0.0f;
selectionOffsetY_ = 0.0f;
dragBackgroundSnapshot_ = nullptr;
nonSelectedSnapshot_ = nullptr;
selectedSnapshot_ = nullptr;
selectionHighlightSnapshot_ = nullptr;
resetIndexedSelectionState();
// Reset history. Loading a serialized notebook is treated as a
// checkpoint -- the user wouldn't expect to undo past the load.
undoStack_.clear();
Expand Down
4 changes: 4 additions & 0 deletions cpp/SkiaDrawingEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ class SkiaDrawingEngine {
void commitDelta(StrokeDelta&& delta);
void applyDelta(const StrokeDelta& delta); // forward (used by redo)
void revertDelta(const StrokeDelta& delta); // backward (used by undo)
// Selection and object-eraser state store positions into strokes_. Call
// whenever strokes_ is rebuilt or compacted (undo/redo/clear/load/object
// erase) so no stale index can address a different or missing stroke.
void resetIndexedSelectionState();

// Pixel-eraser accumulator. During an eraser drag (touchBegan eraser
// -> touchMoved... -> touchEnded), applyPixelEraserAt only collects
Expand Down
3 changes: 2 additions & 1 deletion cpp/SkiaDrawingEngineEraser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ void SkiaDrawingEngine::eraseObjects() {
std::unordered_map<size_t, size_t> oldToNew;
size_t newIdx = 0;
std::vector<Stroke> remaining;
remaining.reserve(strokes_.size() - indicesToRemove.size());
remaining.reserve(strokes_.size() - delta.removedStrokes.size());

for (size_t i = 0; i < strokes_.size(); ++i) {
if (indicesToRemove.count(i) == 0) {
Expand All @@ -49,6 +49,7 @@ void SkiaDrawingEngine::eraseObjects() {
}
if (remaining.size() != strokes_.size()) {
strokes_ = remaining;
resetIndexedSelectionState();
commitDelta(std::move(delta));
markStrokeCachesDirty();
}
Expand Down
12 changes: 12 additions & 0 deletions cpp/SkiaDrawingEngineSelection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ void SkiaDrawingEngine::clearSelection() {
}
}

void SkiaDrawingEngine::resetIndexedSelectionState() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);

// Transform state is owned by cancelSelectionTransform(), which callers
// run before rebuilding strokes_ so its originals restore into place.
// clearSelection() is not reused: drag snapshots must be freed even
// when the selection is already empty.
selectedIndices_.clear();
pendingDeleteIndices_.clear();
endSelectionDrag();
}

void SkiaDrawingEngine::deleteSelection() {
std::lock_guard<std::recursive_mutex> lock(stateMutex_);

Expand Down
21 changes: 21 additions & 0 deletions ios/MobileInkModule/MobileInkCanvasView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -995,25 +995,46 @@ class MobileInkCanvasView: MTKView {

@objc func clear() {
guard let engine = drawingEngine else { return }
let hadSelection = getSelectionCount(engine) > 0
clearCanvas(engine)
endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection)
requestDisplay()
onDrawingChange?([:])
}

@objc func undo() {
guard let engine = drawingEngine else { return }
let hadSelection = getSelectionCount(engine) > 0
undoStroke(engine)
endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection)
requestDisplay()
onDrawingChange?([:])
}

@objc func redo() {
guard let engine = drawingEngine else { return }
let hadSelection = getSelectionCount(engine) > 0
redoStroke(engine)
endSelectionInteractionAfterHistoryChange(hadSelection: hadSelection)
requestDisplay()
onDrawingChange?([:])
}

/// Undo, redo and clear rebuild the stroke list, so the engine drops its
/// selection. Mirror that here: stop any in-flight move/transform and hide
/// the selection toolbar so it cannot act on strokes that are gone.
/// An in-progress lasso is kept: it selects against the new strokes on
/// pen-up.
private func endSelectionInteractionAfterHistoryChange(hadSelection: Bool) {
isMovingSelection = false
isTransformingSelection = false
selectionTransformHandleIndex = -1
hasSelectionMoveDelta = false
if hadSelection {
notifySelectionChange()
}
}

// MARK: - Eraser Cursor

private func setupEraserCursor() {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mathnotes/mobile-ink",
"version": "0.3.4",
"version": "0.3.5",
"description": "Production-grade React Native ink engine with native Skia drawing and continuous canvas primitives.",
"license": "Apache-2.0",
"author": "BuilderPro LLC",
Expand Down Expand Up @@ -42,11 +42,12 @@
"test:release": "node --test scripts/*.test.mjs",
"test:native:smoke": "clang++ -std=c++20 scripts/drawing_serialization_smoke.cpp cpp/DrawingTypes.cpp cpp/DrawingSerialization.cpp cpp/ShapeRecognition.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_drawing_serialization_smoke && /tmp/mobile_ink_drawing_serialization_smoke",
"test:native:eraser-smoke": "clang++ -std=c++20 scripts/eraser_cursor_smoke.cpp cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_eraser_cursor_smoke && /tmp/mobile_ink_eraser_cursor_smoke",
"test:native:selection-smoke": "clang++ -std=c++20 scripts/selection_history_smoke.cpp cpp/*.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_selection_history_smoke && /tmp/mobile_ink_selection_history_smoke",
"test:example:typecheck": "npm --prefix example run typecheck",
"test:example:export:ios": "npm --prefix example run export:ios",
"test:example:export:android": "npm --prefix example run export:android",
"pack:dry-run": "npm pack --dry-run",
"validate": "npm run typecheck && npm run test && npm run test:release && npm run test:native:smoke && npm run test:native:eraser-smoke && npm run build && npm run pack:dry-run && npm run test:example:typecheck && npm run test:example:export:ios && npm run test:example:export:android",
"validate": "npm run typecheck && npm run test && npm run test:release && npm run test:native:smoke && npm run test:native:eraser-smoke && npm run test:native:selection-smoke && npm run build && npm run pack:dry-run && npm run test:example:typecheck && npm run test:example:export:ios && npm run test:example:export:android",
"prepack": "npm run build"
},
"peerDependencies": {
Expand Down
Loading
Loading