Skip to content

Erase, select and transform

Operations act on List<PageStroke> and return new display lists. Persist the matching stored operation separately, then replay it to confirm the saved result. Each PageStroke.id is a stored row ID; projectionKey identifies one live disconnected piece.

Partial erasing

Build an eraser mask in page coordinates. Round-trip it before hit testing so the preview uses the quantized geometry that a reload will reconstruct. Normal removes only crossed ink.

package wiki

import androidx.ink.brush.InputToolType
import androidx.ink.strokes.MutableStrokeInputBatch
import com.vivenotes.byteink.kit.InkEraseMode
import com.vivenotes.byteink.kit.InkPageIndex
import com.vivenotes.byteink.kit.PageStroke
import com.vivenotes.byteink.kit.StoredInkErase
import com.vivenotes.byteink.kit.ViveInkCodec
import com.vivenotes.byteink.kit.subtract

data class ErasePreview(val operation: StoredInkErase, val projections: List<PageStroke>)

fun partialErase(page: List<PageStroke>): ErasePreview {
    val inputs = MutableStrokeInputBatch().apply {
        add(type = InputToolType.MOUSE, x = 50f, y = 30f, elapsedTimeMillis = 0L)
        add(type = InputToolType.MOUSE, x = 50f, y = 70f, elapsedTimeMillis = 40L)
    }
    val mask = requireNotNull(ViveInkCodec.reloadedEraseMask(inputs, sizeDp = 18f))
    val targets = InkPageIndex(page).targetsFor(mask)
    val operation = ViveInkCodec.encodeErase(
        mask = mask,
        id = "erase-1",
        pageId = "page-1",
        mode = InkEraseMode.Normal,
        createdAt = 43L,
        targetIds = targets,
    )
    return ErasePreview(operation, page.subtract(mask, targets))
}

The example returns the stored erase plus preview projections. Persist the erase row and target links together. Keep the original stroke rows. On reload, pass the original rows and erase operations to ViveInkPage.load.

The ViveNotes desktop app now implements this workflow: Normal gestures collect real current/historical pointer samples in page dp, preview the decoded round mask, persist immutable mask/target rows and share ink undo/redo history. Its Object UI mode retains whole-row erasing; the library's eraseObjects API instead removes touched disconnected components.

For your own application:

  1. Model masks and subtract/replay geometry on a worker thread. An empty-space gesture should create no operation.
  2. Persist one immutable erase row and its exact same-page targets in a transaction. Keep original stroke and unknown-data bytes.
  3. Undo by setting the erase's deletedAt; redo by clearing it on the same row with the same mask and targets. Allocate operation clocks monotonically, including inactive operations.
  4. Rebuild from original source projections and active decoded operations with ViveInkPage.replay; use load after database reopen. Keep source draw order and earlier moves/erases.

Cancel unfinished gestures when page, tool, mode or coordinate transforms change. If gestures overlap worker completion, serialize completions and rebase previews onto earlier completed masks; reject results from replaced page snapshots. A Normal preview rebuilds affected geometry and does not inherit whole-row erase's bounded cache work.

Linux and Windows app interaction, persistence, undo/redo and reload checks pass. The extended Android round trip covers three newly authored Normal masks (marker, calligraphy and highlighter), two Object masks and 55 strokes: 64/64 exact fresh encoding comparisons and zero replay geometry/software-pixel differences. See the round-trip contract.

Object erase and whole-row erase

Behavior API / storage
Cut crossed portions subtract(mask, targetIds); stored InkEraseMode.Normal
Remove touched disconnected components eraseObjects(mask, targetIds); stored InkEraseMode.Object
Remove every piece of a row Filter/tombstone its row ID; the desktop app's current whole-stroke eraser uses this

For Object, use the same mask/target workflow as the example and change both mode and display operation. Highlighters keep their outline geometry as one projection; touching an Object-erased highlighter removes it entirely.

Never apply an old mask to every newer stroke on reload. Persist the exact targets collected for that gesture.

Hit testing

val index = InkPageIndex(page)
val nearPointer = index.at(point = InkPoint(100f, 80f), reach = 6f)
val crossed = index.crossing(
    from = InkPoint(90f, 80f),
    to = InkPoint(110f, 80f),
    width = 12f,
)
val touchedRows = index.targetsFor(mask)

at uses a square of half-side reach; crossing uses a rectangular band of diameter width between samples. For rounded brush-mask hits, use touching(mask) or targetsFor(mask). Build a new index when the page geometry changes; previews can reuse an unchanged index.

SpatialIndex in core offers conservative bounds queries for your own items. Follow those candidates with exact native geometry tests when required.

Lasso move and resize

package wiki

import com.vivenotes.byteink.kit.InkLassoMove
import com.vivenotes.byteink.kit.InkPoint
import com.vivenotes.byteink.kit.PageBounds
import com.vivenotes.byteink.kit.PageStroke
import com.vivenotes.byteink.kit.StoredInkMove
import com.vivenotes.byteink.kit.ViveInkCodec
import com.vivenotes.byteink.kit.moveSelected
import com.vivenotes.byteink.kit.selectInkWithLasso

fun moveWithLasso(page: List<PageStroke>): Pair<List<PageStroke>, StoredInkMove>? {
    val loop = listOf(
        InkPoint(0f, 0f), InkPoint(120f, 0f), InkPoint(120f, 120f),
        InkPoint(0f, 120f), InkPoint(0f, 0f),
    )
    val selection = page.selectInkWithLasso(path = loop) ?: return null
    val delta = PageBounds.clampTranslation(selection.bounds, dx = 20f, dy = 10f)
    val move = InkLassoMove(
        path = selection.path,
        targetIds = selection.targetIds,
        projections = selection.projections,
        dx = delta.x,
        dy = delta.y,
    )
    val row = ViveInkCodec.encodeMove(move, id = "move-1", pageId = "page-1", createdAt = 44L)
    return page.moveSelected(move) to row
}

selectInkWithLasso requires a gesture that closes into a loop; selectWithLasso accepts the polygon directly for stored/replayed paths. Default edge tolerance is 4f page dp. Selecting one grouped stroke includes all projections in that group. A selected disconnected piece carries its own projection key.

Before committing a live move/resize, use PageBounds.clampTranslation or clampScale with the combined selection bounds. moveSelected and resizeSelected apply supplied deltas directly; replay clamps the final result against the page's origin. Persist the actual applied delta/scale.

For a stored move row, apply translation first and resize second, using its path and target IDs. ViveInkPage.load handles that order. The encode helpers record the original lasso path so replay can re-identify pieces.

Delete, recolor, group and copy

Operation Use and persistence
planProjectionDelete(held) Returns proved piece Object erases, whole-row tombstones and after; persist both operation kinds. Some pieces remain if a safe isolated mask cannot be proved.
recolor(ids, colorArgb) Returns recolored geometry and clears automatic color flags; update source row color/theme fields in your repository.
regroup(groups) Updates projection group IDs; persist row group changes. A null value ungroups.
translatedCopy(dx, dy) Bakes a projection's current page transform and new offset into copied inputs; encode with encodeCopy and a new row ID/sequence.
keepingProjectionsOf(previous) Retains live keys for matching rebuilt row/piece/bounds; helps keep selections across a reload.
pointOnInk() Finds a page-space point on the projection, or returns null for no provable geometry.

Projection numbers are process-local and must never be written as row identity. Keep partial-erase and move operations when copying a notebook; materializing display projections as replacement rows loses replay/undo history.

Every operation parameter ยท Codec parameters