neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Make editing a mode you enter, not the default
731ef80 · Gabriel Morell · 2026-09-16 03:46
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+4
−1
@@ -163,7 +163,10 @@ directly.
| 163 | 163 | |
| 164 | 164 | ## Editing |
| 165 | 165 | |
| 166 | −Tap selects, tapping the selection opens the sheet, dragging moves, and | |
| 166 | +Editing is a distinct mode: the pencil in the app bar turns it on, the | |
| 167 | +check turns it off. In view mode the canvas only pans, zooms and follows | |
| 168 | +link badges, so a pan that starts on a shape stays a pan. In edit mode | |
| 169 | +tap selects, tapping the selection opens the sheet, dragging moves, and | |
| 167 | 170 | the corner handles resize. Connectors are rendered but not authored here |
| 168 | 171 | — picking an anchor on a phone is its own design problem, and it is not |
| 169 | 172 | what mobile users are blocked on. |
modified
android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasScreen.kt
+75
−15
@@ -18,10 +18,13 @@ import androidx.compose.material.icons.Icons
| 18 | 18 | import androidx.compose.material.icons.automirrored.filled.ArrowBack |
| 19 | 19 | import androidx.compose.material.icons.filled.Add |
| 20 | 20 | import androidx.compose.material.icons.filled.CenterFocusStrong |
| 21 | +import androidx.compose.material.icons.filled.Check | |
| 22 | +import androidx.compose.material.icons.filled.Edit | |
| 21 | 23 | import androidx.compose.material3.CircularProgressIndicator |
| 22 | 24 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 23 | 25 | import androidx.compose.material3.FloatingActionButton |
| 24 | 26 | import androidx.compose.material3.Icon |
| 27 | +import androidx.compose.material3.LocalContentColor | |
| 25 | 28 | import androidx.compose.material3.IconButton |
| 26 | 29 | import androidx.compose.material3.MaterialTheme |
| 27 | 30 | import androidx.compose.material3.Scaffold |
@@ -94,9 +97,23 @@ fun CanvasScreen(
| 94 | 97 | var liveError by remember { mutableStateOf<String?>(null) } |
| 95 | 98 | var selectedId by remember { mutableStateOf<String?>(null) } |
| 96 | 99 | var drag by remember { mutableStateOf<DragState?>(null) } |
| 100 | + // Editing is a mode you enter deliberately. A canvas is opened far | |
| 101 | + // more often to look at than to change, and on a touch screen the | |
| 102 | + // gestures for the two overlap: without this, every pan that starts | |
| 103 | + // on a shape is a move, and the edit is already sent before the | |
| 104 | + // finger lifts. | |
| 105 | + var editing by rememberSaveable { mutableStateOf(false) } | |
| 97 | 106 | var showAddSheet by remember { mutableStateOf(false) } |
| 98 | 107 | var showSelectionSheet by remember { mutableStateOf(false) } |
| 99 | 108 | val snackbars = remember { SnackbarHostState() } |
| 109 | + fun leaveEditing() { | |
| 110 | + editing = false | |
| 111 | + selectedId = null | |
| 112 | + showSelectionSheet = false | |
| 113 | + showAddSheet = false | |
| 114 | + drag = null | |
| 115 | + } | |
| 116 | + | |
| 100 | 117 | |
| 101 | 118 | // The session holds the document; this counter is what tells Compose |
| 102 | 119 | // it changed, since JsonObject is not a Compose-observable type. |
@@ -179,6 +196,11 @@ fun CanvasScreen(
| 179 | 196 | live = LiveState.Live |
| 180 | 197 | sender = event.sender |
| 181 | 198 | meta = meta?.copy(role = event.role ?: meta?.role) |
| 199 | + | |
| 200 | + // The join reply is authoritative about the role, | |
| 201 | + // and it can demote: leave the mode rather than | |
| 202 | + // offer controls whose every use will be refused. | |
| 203 | + if (meta?.canWrite == false) leaveEditing() | |
| 182 | 204 | } |
| 183 | 205 | |
| 184 | 206 | is CanvasEvent.Op -> { |
@@ -221,6 +243,7 @@ fun CanvasScreen(
| 221 | 243 | } |
| 222 | 244 | |
| 223 | 245 | val canWrite = meta?.canWrite ?: false |
| 246 | + val editable = canWrite && editing | |
| 224 | 247 | val selected = document.elements[selectedId] |
| 225 | 248 | |
| 226 | 249 | // Gesture detectors must not be keyed on `revision`: a drag sends ops |
@@ -299,6 +322,23 @@ fun CanvasScreen(
| 299 | 322 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") |
| 300 | 323 | } |
| 301 | 324 | }, |
| 325 | + actions = { | |
| 326 | + if (canWrite) { | |
| 327 | + IconButton( | |
| 328 | + onClick = { if (editing) leaveEditing() else editing = true }, | |
| 329 | + ) { | |
| 330 | + Icon( | |
| 331 | + if (editing) Icons.Default.Check else Icons.Default.Edit, | |
| 332 | + contentDescription = if (editing) "Done editing" else "Edit", | |
| 333 | + tint = if (editing) { | |
| 334 | + MaterialTheme.colorScheme.primary | |
| 335 | + } else { | |
| 336 | + LocalContentColor.current | |
| 337 | + }, | |
| 338 | + ) | |
| 339 | + } | |
| 340 | + } | |
| 341 | + }, | |
| 302 | 342 | ) |
| 303 | 343 | }, |
| 304 | 344 | snackbarHost = { SnackbarHost(snackbars) }, |
@@ -310,9 +350,9 @@ fun CanvasScreen(
| 310 | 350 | } |
| 311 | 351 | } |
| 312 | 352 | |
| 313 | − // No add button for a viewer: an edit that is always | |
| 314 | − // refused is worse than no button at all. | |
| 315 | − if (canWrite) { | |
| 353 | + // Only while editing, and never for a viewer: an | |
| 354 | + // action that is always refused is worse than no button. | |
| 355 | + if (editable) { | |
| 316 | 356 | FloatingActionButton(onClick = { showAddSheet = true }) { |
| 317 | 357 | Icon(Icons.Default.Add, contentDescription = "Add a shape") |
| 318 | 358 | } |
@@ -321,7 +361,7 @@ fun CanvasScreen(
| 321 | 361 | }, |
| 322 | 362 | ) { padding -> |
| 323 | 363 | Column(modifier = Modifier.fillMaxSize().padding(padding)) { |
| 324 | − LiveBanner(live, liveError, peers, meta?.canWrite ?: true) | |
| 364 | + LiveBanner(live, liveError, peers, canWrite, editing) | |
| 325 | 365 | |
| 326 | 366 | Box(modifier = Modifier.fillMaxSize()) { |
| 327 | 367 | when { |
@@ -342,8 +382,8 @@ fun CanvasScreen(
| 342 | 382 | // grab something, so anything that isn't a |
| 343 | 383 | // handle or a selected shape falls through to |
| 344 | 384 | // pan and zoom. |
| 345 | − .pointerInput(canWrite) { | |
| 346 | − if (!canWrite) return@pointerInput | |
| 385 | + .pointerInput(editable) { | |
| 386 | + if (!editable) return@pointerInput | |
| 347 | 387 | |
| 348 | 388 | awaitEachGesture { |
| 349 | 389 | val down = awaitFirstDown(requireUnconsumed = false) |
@@ -404,7 +444,11 @@ fun CanvasScreen(
| 404 | 444 | val hit = currentDocument.elementAt(x, y) |
| 405 | 445 | |
| 406 | 446 | when { |
| 447 | + // Links stay live in view mode: | |
| 448 | + // following one is reading, not | |
| 449 | + // editing. | |
| 407 | 450 | link != null -> openLink(context, link, onOpenCanvas) |
| 451 | + !editable -> Unit | |
| 408 | 452 | hit == null -> selectedId = null |
| 409 | 453 | hit.id == selectedId -> showSelectionSheet = true |
| 410 | 454 | else -> selectedId = hit.id |
@@ -426,7 +470,7 @@ fun CanvasScreen(
| 426 | 470 | document = document, |
| 427 | 471 | viewport = viewport, |
| 428 | 472 | preview = drag, |
| 429 | − selectedId = selectedId, | |
| 473 | + selectedId = selectedId.takeIf { editable }, | |
| 430 | 474 | // A shape a peer is dragging is ghosted, so a |
| 431 | 475 | // position that is about to change doesn't |
| 432 | 476 | // read as the truth. |
@@ -438,10 +482,10 @@ fun CanvasScreen(
| 438 | 482 | |
| 439 | 483 | if (!loading && error == null && document.elements.isEmpty()) { |
| 440 | 484 | Text( |
| 441 | − if (canWrite) { | |
| 442 | − "This canvas is empty. Use + to add a shape." | |
| 443 | − } else { | |
| 444 | − "This canvas is empty." | |
| 485 | + when { | |
| 486 | + editable -> "This canvas is empty. Use + to add a shape." | |
| 487 | + canWrite -> "This canvas is empty. Tap the pencil to add a shape." | |
| 488 | + else -> "This canvas is empty." | |
| 445 | 489 | }, |
| 446 | 490 | modifier = Modifier.align(Alignment.Center), |
| 447 | 491 | style = MaterialTheme.typography.bodyMedium, |
@@ -563,26 +607,42 @@ private fun LiveBanner(
| 563 | 607 | error: String?, |
| 564 | 608 | peers: List<Peer>, |
| 565 | 609 | canWrite: Boolean, |
| 610 | + editing: Boolean, | |
| 566 | 611 | ) { |
| 612 | + // Connection trouble outranks everything else: it is the one state | |
| 613 | + // that makes what is on screen untrustworthy. | |
| 567 | 614 | val message = when { |
| 568 | 615 | live == LiveState.Connecting -> "Connecting…" |
| 569 | 616 | live == LiveState.Offline -> |
| 570 | 617 | error ?: "Offline — this view won't update until it reconnects." |
| 618 | + editing -> "Editing — drag a shape to move it, tap it for options" + peersSuffix(peers) | |
| 571 | 619 | !canWrite -> "Read-only" + peersSuffix(peers) |
| 572 | 620 | peers.isNotEmpty() -> peers.joinToString(", ") { it.name } + " also here" |
| 573 | 621 | else -> null |
| 574 | 622 | } ?: return |
| 575 | 623 | |
| 576 | − val background = when (live) { | |
| 577 | − LiveState.Live -> MaterialTheme.colorScheme.surfaceVariant | |
| 578 | − else -> MaterialTheme.colorScheme.errorContainer | |
| 624 | + // A mode that changes what a gesture does has to be visible without | |
| 625 | + // looking for it, so editing takes the full primary rather than the | |
| 626 | + // container tone -- which in these palettes is base-200 and lands on | |
| 627 | + // the same dark strip as every other message. | |
| 628 | + val (background, foreground) = when { | |
| 629 | + live != LiveState.Live -> | |
| 630 | + MaterialTheme.colorScheme.errorContainer to | |
| 631 | + MaterialTheme.colorScheme.onErrorContainer | |
| 632 | + | |
| 633 | + editing -> | |
| 634 | + MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary | |
| 635 | + | |
| 636 | + else -> | |
| 637 | + MaterialTheme.colorScheme.surfaceVariant to | |
| 638 | + MaterialTheme.colorScheme.onSurfaceVariant | |
| 579 | 639 | } |
| 580 | 640 | |
| 581 | 641 | Row( |
| 582 | 642 | modifier = Modifier.fillMaxWidth().background(background).padding(8.dp), |
| 583 | 643 | horizontalArrangement = Arrangement.Center, |
| 584 | 644 | ) { |
| 585 | − Text(message, style = MaterialTheme.typography.bodySmall) | |
| 645 | + Text(message, style = MaterialTheme.typography.bodySmall, color = foreground) | |
| 586 | 646 | } |
| 587 | 647 | } |
| 588 | 648 |
modified
docs/android.md
+12
−2
@@ -593,8 +593,18 @@ Gesture detectors are deliberately not keyed on the document revision: a
| 593 | 593 | drag sends ops as it goes, and restarting the `pointerInput` on the |
| 594 | 594 | resulting recomposition cancels the gesture that produced them. |
| 595 | 595 | |
| 596 | −A viewer gets no handles, no add button and no editing controls. An | |
| 597 | −action that is always refused is worse than no button at all. | |
| 596 | +Editing is a mode, entered from the pencil in the app bar. A canvas is | |
| 597 | +opened far more often to look at than to change, and on a touch screen | |
| 598 | +the two gesture sets overlap — without the mode, every pan that starts on | |
| 599 | +a shape is a move, and the op is sent before the finger lifts. In view | |
| 600 | +mode only pan, zoom and link badges respond; following a link is reading, | |
| 601 | +not editing. The mode announces itself in the primary colour, because a | |
| 602 | +mode that changes what a gesture does has to be visible without looking | |
| 603 | +for it. | |
| 604 | + | |
| 605 | +A viewer never gets the toggle, and a join reply that demotes the role | |
| 606 | +drops out of the mode: an action that is always refused is worse than no | |
| 607 | +button at all. | |
| 598 | 608 | |
| 599 | 609 | ### Phase 5 — Polish |
| 600 | 610 | - Export (JSON + mermaid) to the share sheet, canvas create/rename/theme, |
Parents: b60324d