Skip to content

This vignette demonstrates how to build a collaborative meeting notes application using autoedit’s editor() widget with a sync server, and explains why simpler approaches using standard textareas are not suitable for real-time collaboration.

Setup

To run these examples, you need the autoedit package installed:

# install.packages("pak")
pak::pak("shikokuchuo/autoedit")

Open multiple browser windows or tabs pointing to the same Shiny app URL to see real-time collaboration in action.

Collaborative editor with sync server

The editor() widget provides the best collaborative editing experience. It uses CodeMirror 6 with the automerge-codemirror integration, which applies Automerge operations directly to CodeMirror’s document model. This means your cursor stays in place when others edit, you get full syntax highlighting, and documents can be saved and restored across sessions.

Editor example with autosync

The following example uses the autosync package, which provides an in-process Automerge sync server:

library(shiny)
library(bslib)
library(autoedit)
library(autosync)
library(automerge)

# Room-specific initial content templates
room_templates <- list(
  standup = "# Daily Standup\n\n## What I did yesterday\n- \n\n## What I'm doing today\n- \n\n## Blockers\n- \n",
  planning = "# Sprint Planning\n\n## Goals\n- \n\n## User Stories\n- \n\n## Capacity\n- \n",
  retrospective = "# Retrospective\n\n## What went well\n- \n\n## What could be improved\n- \n\n## Action items\n- \n",
  brainstorm = "# Brainstorming Session\n\n## Ideas\n- \n\n## Discussion Notes\n- \n"
)

# Create and start the sync server (once, at app startup)
sync_server <- amsync_server()
sync_server$start()

# Create a document for each room with initial content
doc_ids <- list()
for (room in names(room_templates)) {
  doc_ids[[room]] <- create_document(sync_server)
  doc <- get_document(sync_server, doc_ids[[room]])
  am_put(doc, AM_ROOT, "text", am_text(room_templates[[room]]))
  am_commit(doc, "init")
}

ui <- page_fillable(
  padding = "1rem",
  div(class = "d-flex justify-content-between align-items-center mb-3",
    h2("Collaborative Meeting Notes", class = "mb-0"),
    downloadButton("export", "Export as Quarto", class = "btn-sm")
  ),
  navset_card_pill(
    id = "room",
    nav_panel("Daily Standup", value = "standup", uiOutput("editor_standup")),
    nav_panel("Sprint Planning", value = "planning", uiOutput("editor_planning")),
    nav_panel("Retrospective", value = "retrospective", uiOutput("editor_retrospective")),
    nav_panel("Brainstorming", value = "brainstorm", uiOutput("editor_brainstorm"))
  )
)

server <- function(input, output, session) {
  output$editor_standup <- renderUI(editor_output("ed_standup", height = "500px"))
  output$editor_planning <- renderUI(editor_output("ed_planning", height = "500px"))
  output$editor_retrospective <- renderUI(editor_output("ed_retrospective", height = "500px"))
  output$editor_brainstorm <- renderUI(editor_output("ed_brainstorm", height = "500px"))

  output$ed_standup <- editor_render(editor(sync_server$url, doc_ids[["standup"]], height = "500px"))
  output$ed_planning <- editor_render(editor(sync_server$url, doc_ids[["planning"]], height = "500px"))
  output$ed_retrospective <- editor_render(editor(sync_server$url, doc_ids[["retrospective"]], height = "500px"))
  output$ed_brainstorm <- editor_render(editor(sync_server$url, doc_ids[["brainstorm"]], height = "500px"))

  current_content <- reactive({
    switch(input$room,
      "standup" = input$ed_standup_content,
      "planning" = input$ed_planning_content,
      "retrospective" = input$ed_retrospective_content,
      "brainstorm" = input$ed_brainstorm_content
    )
  })

  output$export <- downloadHandler(
    filename = function() paste0(input$room, "-notes-", Sys.Date(), ".qmd"),
    content = function(file) {
      titles <- c(standup = "Daily Standup", planning = "Sprint Planning",
                  retrospective = "Retrospective", brainstorm = "Brainstorming")
      front_matter <- sprintf("---\ntitle: \"%s\"\ndate: \"%s\"\nformat: html\n---\n\n",
                              titles[input$room], Sys.Date())
      writeLines(paste0(front_matter, current_content()), file)
    }
  )
}

# Clean up when app stops
onStop(function() sync_server$close())

shinyApp(ui, server)

Why not use a standard textarea?

You might wonder whether a simpler approach using Shiny’s textAreaInput() could work for collaborative editing. The autoedit package does include textarea_ui() and textarea_server() functions that synchronize text using Automerge without requiring a sync server. However, this approach has a fundamental limitation that makes it unsuitable for real-time collaboration.

The cursor position problem

HTML <textarea> elements have no API for granular text updates. The only way to update a textarea programmatically is to replace its entire .value property. This means that when another user’s changes arrive, the cursor position is lost.

You can demonstrate this problem with the following app:

library(shiny)
library(autoedit)

ui <- fluidPage(
  h3("Cursor Reset Demo"),
  p("1. Click in the middle of the text below"),
  p("2. Click 'Simulate Remote Edit' - watch your cursor jump"),
  textarea_ui("notes", height = "200px"),

  actionButton("remote_edit", "Simulate Remote Edit")
)

server <- function(input, output, session) {
  textarea_server(
    "notes",
    doc_id = "cursor-test",
    initial_text = "Place your cursor HERE in the middle then click the button."
  )

  # Directly modify the master doc to simulate another user's edit
  observeEvent(input$remote_edit, {
    master <- autoedit:::.master_docs[["cursor-test"]]
    if (!is.null(master)) {
      text_obj <- automerge::am_get(master$doc, automerge::AM_ROOT, "text")
      content <- automerge::am_text_content(text_obj)
      # Append text to simulate remote edit
      automerge::am_text_splice(text_obj, nchar(content), 0L, " [REMOTE EDIT]")
      automerge::am_commit(master$doc, "remote edit")
      master$version <- master$version + 1L
    }
  })
}

shinyApp(ui, server)

When you click the button, your cursor jumps to the end of the text. In a real collaborative scenario, this would happen every time another user makes an edit, making it impossible to type continuously.

Why CodeMirror works

The editor() widget uses CodeMirror 6 with the automerge-codemirror integration. CodeMirror maintains a proper document model that can apply operations incrementally. When a remote change arrives, CodeMirror:

  1. Applies the change at the correct position
  2. Adjusts cursor and selection positions to account for the change
  3. Preserves your editing context

This is why editor() with a sync server is the recommended approach for collaborative text editing.

Alternatives for serverless collaboration

If you need serverless collaboration without the cursor position issues, consider using discrete UI components instead of free-form text. For example, a collaborative kanban board works well because each action (add item, move item, toggle, delete) is atomic - there’s no cursor position to preserve. See the “Collaborative Kanban Board” vignette for details.