Make editing a mode you enter, not the default

731ef80 · Gabriel Morell · 2026-09-16 03:46

14 files +91 -18
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